From 57706270028011d45c07ff6bf4b7dfc4bbfc15b0 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 01:56:19 +0200 Subject: [PATCH 01/28] fix(core): answer an unoffered currency with a null quote instead of failing the batch --- .changeset/rates-batch-unknown-code.md | 7 +++++++ .../service/exchange-rate.service.ts | 14 ++++++++------ .../pam/profile/service/profile.service.ts | 2 +- .../__tests__/display-currency.e2e.test.ts | 16 ++++++++++++++++ .../src/__tests__/exchange-rate.e2e.test.ts | 19 +++++++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 .changeset/rates-batch-unknown-code.md diff --git a/.changeset/rates-batch-unknown-code.md b/.changeset/rates-batch-unknown-code.md new file mode 100644 index 000000000..ab35a32a5 --- /dev/null +++ b/.changeset/rates-batch-unknown-code.md @@ -0,0 +1,7 @@ +--- +'@openora/core': patch +--- + +`GET /exchange-rate/rates` answers a source currency the operator does not offer with a null quote instead of rejecting the whole batch. + +`GET /profile/display-currency` ignores a saved pick the operator no longer offers and falls back to the player's most valuable balance. 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/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/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); + }); +}); From 2f36a3ff8e293712e6028707649b8d6dc65f00a6 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 03:02:45 +0200 Subject: [PATCH 02/28] feat(wallet): accept optional bonus grant terms on a credit WALLET_COMMANDS.credit had no way for a caller to say what wagering multiplier or expiry a gift/rain credit's underlying bonus grant should carry, so every such grant silently fell back to the bonus module's hardcoded default regardless of what an operator configured. Forward the terms BONUS_GRANTS.grant already accepts. Omitting it keeps today's behavior exactly. --- .changeset/wallet-credit-grant-terms.md | 5 ++ .../src/contracts/adapters/wallet-commands.ts | 7 +++ .../wallet-commands.service.int.test.ts | 51 ++++++++++++++++++- .../wallet/service/wallet-commands.service.ts | 2 + 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/wallet-credit-grant-terms.md diff --git a/.changeset/wallet-credit-grant-terms.md b/.changeset/wallet-credit-grant-terms.md new file mode 100644 index 000000000..17daa9d76 --- /dev/null +++ b/.changeset/wallet-credit-grant-terms.md @@ -0,0 +1,5 @@ +--- +'@openora/core': minor +--- + +`WALLET_COMMANDS.credit` now accepts optional bonus grant `terms` (wagering multiplier, expiry days, and the rest of `BonusGrantTerms`) for `gift`/`rain` credits, forwarded to `BONUS_GRANTS.grant`. Omitting it keeps today's behaviour: the bonus module's own default terms. diff --git a/packages/core/src/contracts/adapters/wallet-commands.ts b/packages/core/src/contracts/adapters/wallet-commands.ts index 167be1b8b..f80dbb9b9 100644 --- a/packages/core/src/contracts/adapters/wallet-commands.ts +++ b/packages/core/src/contracts/adapters/wallet-commands.ts @@ -5,6 +5,7 @@ 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'; export type WalletProviderRef = { providerName: string; @@ -64,6 +65,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/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/service/wallet-commands.service.ts b/packages/core/src/wallet/service/wallet-commands.service.ts index 464edf0ad..16b062af0 100644 --- a/packages/core/src/wallet/service/wallet-commands.service.ts +++ b/packages/core/src/wallet/service/wallet-commands.service.ts @@ -336,6 +336,7 @@ export class WalletCommandsService implements WalletCommands { allowNewCurrency, allowNewWallet, providerRef, + terms, }: WalletCreditArgs, ): Promise { const txn = tx as DrizzleDb; @@ -386,6 +387,7 @@ export class WalletCommandsService implements WalletCommands { source: type, sourceRef: providerRef.providerRefId, actor: { type: 'system' }, + terms, }); if (!granted.ok) { throw new WalletBonusGrantRefusedError(granted.reason); From 2c4efad9abd8946d345dcc86bf7d76713a14a5ce Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 03:47:55 +0200 Subject: [PATCH 03/28] feat(notifications): add player-to-player transfer notification types Player-to-player transfers (a chat gift, a tip) are a generic in-app-transfer concept most operators building on the platform will want notifications for, same as the existing chat.rain.received type. Adds chat.tip.received, chat.gift.claimed and chat.gift.expired to the notification type enum, additive only. --- .changeset/notification-p2p-transfer-types.md | 5 +++++ .../__tests__/notification-types.test.ts | 19 +++++++++++++++++++ .../notifications/contract/index.ts | 3 +++ 3 files changed, 27 insertions(+) create mode 100644 .changeset/notification-p2p-transfer-types.md create mode 100644 packages/core/src/engagement/notifications/__tests__/notification-types.test.ts diff --git a/.changeset/notification-p2p-transfer-types.md b/.changeset/notification-p2p-transfer-types.md new file mode 100644 index 000000000..c5e664b42 --- /dev/null +++ b/.changeset/notification-p2p-transfer-types.md @@ -0,0 +1,5 @@ +--- +'@openora/core': minor +--- + +Adds three notification types for player-to-player transfers: `chat.tip.received`, `chat.gift.claimed`, `chat.gift.expired`, alongside the existing `chat.rain.received`. Additive to the closed `NotificationTypeSchema` enum; no consumer of the existing types is affected. 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/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index d8bceb227..cd0f7098f 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -12,6 +12,9 @@ export const NOTIFICATION_TYPES = [ 'promo.bonus.granted', 'promo.bonus.completed', 'chat.rain.received', + 'chat.tip.received', + 'chat.gift.claimed', + 'chat.gift.expired', 'rg.limit.admin_updated', 'deposit.completed', 'balance.adjusted', From 7e8fe5cb5bd1d1aafcc400771aa069fcf03a4c4d Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 03:50:31 +0200 Subject: [PATCH 04/28] feat(notifications): expose NotificationsService as a public subpath An overlay that has decided a player earned an in-app notification had no supported way to create one - the service lived only behind the notifications plugin's own closure, with no seam a sibling module could reach without a banned deep dist import. Publishes the module's own service aggregation (already written, just unwired) at engagement/notifications, mirroring the existing engagement/contracts/notifications and engagement/schema/notifications subpaths. --- packages/core/package.json | 5 +++++ packages/core/tsconfig.json | 1 + 2 files changed, 6 insertions(+) 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/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"], From 167166f5ea01ba1ba59f96011fd019114960ca3c Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 04:12:51 +0200 Subject: [PATCH 05/28] feat(compliance): enforce daily<=weekly<=monthly ordering on RG limits Player self-service (RgSelfServiceService.upsertLimit, both the immediate write and the parked-increase path) and the admin reduce-only override (RgService.setPlayerLimit) now refuse a deposit/wager/loss limit change that would leave the daily/weekly/monthly bounds out of order for the same type, comparing through ExchangeRateReader when sibling periods differ in currency. A missing rate refuses the write rather than skipping the check. Limit removal is exempt - it only loosens ordering. New LimitOrderingViolationError (409/CONFLICT), wired into the compliance router for upsertLimit and setPlayerLimit. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .changeset/rg-limit-ordering.md | 9 + .../__tests__/rg-self-service.int.test.ts | 82 +++++++- .../__tests__/rg.service.int.test.ts | 184 ++++++++++++++++++ packages/core/src/compliance/router/index.ts | 7 +- .../service/rg-self-service.service.ts | 23 +++ .../core/src/compliance/service/rg.service.ts | 121 +++++++++++- 6 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 .changeset/rg-limit-ordering.md diff --git a/.changeset/rg-limit-ordering.md b/.changeset/rg-limit-ordering.md new file mode 100644 index 000000000..61c3a9370 --- /dev/null +++ b/.changeset/rg-limit-ordering.md @@ -0,0 +1,9 @@ +--- +'@openora/core': minor +--- + +Responsible-gambling deposit/wager/loss limits now enforce `daily <= weekly <= monthly` server-side, compared in one currency through `EXCHANGE_RATE_READER` when the sibling periods differ in currency. This applies to a player's own limit changes (`RgSelfServiceService.upsertLimit`, evaluated against the effective value - including a parked raise's pending amount, since that is what applies once confirmed) and to the admin reduce-only override (`RgService.setPlayerLimit`, since a decrease can still cross a sibling bound). A limit removal is never subject to this check - dropping a bound only loosens the ordering, never violates it. + +A missing exchange rate refuses the whole write rather than skipping the check. Both paths throw the new `LimitOrderingViolationError` (mapped to `CONFLICT`/409), carrying `type`, `period`, `conflictingPeriod` and `bound` (the sibling's effective amount and currency) so a client can render e.g. "Weekly limit can't be lower than your daily limit (29 USD)" without parsing the message. + +**Behaviour change:** a limit set that was previously accepted but left `daily`/`weekly`/`monthly` out of order for the same type is now refused. No migration - existing out-of-order rows are left as-is and only block the next attempted change on that type. 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/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) }; }), ); From b02f33be7cd46a18cd623e74110fdbaa08ae5bc9 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 05:27:49 +0200 Subject: [PATCH 06/28] fix(promo): count a real-money wager toward WAGER_TRACKING with no bonus grant wager() early-returned before reaching WAGER_TRACKING whenever a player had no active bonus grant, so a real-money bet advanced nothing on that port - the rank ladder's lifetime wagering only moved for a player mid-bonus. It now reports the bet at its full stake on the no-grant path too. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../streak-engine-and-wager-tracking-fix.md | 13 + .../src/contracts/schemas/platform-config.ts | 12 + .../__tests__/wagering.service.int.test.ts | 65 ++ .../promo/bonus/service/wagering.service.ts | 31 +- .../gamification/__tests__/streak.int.test.ts | 147 +++ .../src/promo/gamification/contract/index.ts | 94 +- .../migrations/0012_bitter_sister_grimm.sql | 51 + .../migrations/meta/0012_snapshot.json | 883 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../core/src/promo/gamification/plugin.ts | 115 ++- .../src/promo/gamification/router/index.ts | 32 + .../src/promo/gamification/schema/index.ts | 127 ++- .../core/src/promo/gamification/seed/index.ts | 14 +- .../service/streak-admin.service.ts | 66 ++ .../service/streak-payout.service.ts | 192 ++++ .../gamification/service/streak.service.ts | 222 +++++ 16 files changed, 2041 insertions(+), 30 deletions(-) create mode 100644 .changeset/streak-engine-and-wager-tracking-fix.md create mode 100644 packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts create mode 100644 packages/core/src/promo/gamification/__tests__/streak.int.test.ts create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json create mode 100644 packages/core/src/promo/gamification/service/streak-admin.service.ts create mode 100644 packages/core/src/promo/gamification/service/streak-payout.service.ts create mode 100644 packages/core/src/promo/gamification/service/streak.service.ts diff --git a/.changeset/streak-engine-and-wager-tracking-fix.md b/.changeset/streak-engine-and-wager-tracking-fix.md new file mode 100644 index 000000000..f8e21dd51 --- /dev/null +++ b/.changeset/streak-engine-and-wager-tracking-fix.md @@ -0,0 +1,13 @@ +--- +'@openora/core': minor +--- + +Fixed `wager()` in the bonus wagering engine: a real-money bet placed with no active bonus grant +never reached `WAGER_TRACKING`, so the rank ladder (and anything else on that port) only advanced +for a player mid-bonus. It now reports the bet at its full stake regardless. + +Added a daily streak engine alongside the rank ladder in `promo/gamification`: a config-driven +daily qualifying wager, a milestone list (bonus, gift-drop, and rank-rakeback-boost rewards), a +UTC close job, an idempotent milestone-payout job, and a top-5 leaderboard. Bound onto the same +sealed `WAGER_TRACKING` port as the rank ladder through a small internal fan-out, so a bet is +still reported to both from one call site. diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 482f29d93..854d04170 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -241,6 +241,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 +256,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/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..30292b2a7 --- /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/service/wagering.service.ts b/packages/core/src/promo/bonus/service/wagering.service.ts index eb7a5007e..c08ce1ef0 100644 --- a/packages/core/src/promo/bonus/service/wagering.service.ts +++ b/packages/core/src/promo/bonus/service/wagering.service.ts @@ -72,16 +72,27 @@ 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. + await this.wagerTracking?.recordWager(tx, { + userId: args.userId, + currency: args.currency, + amount: args.stake, + weightedAmount: args.stake, + context: args.context, + }); + return { + ok: true, + grantId: null, + bonusSpent: ZERO, + weightedAmount: ZERO, + bonusBalanceAfter: ZERO, + completed: null, + }; } // Against the terms snapshot, inside the transaction that moves the money. It is an 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..2265f9b83 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts @@ -0,0 +1,147 @@ +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) => + db.drizzle.db.transaction((tx) => + streaks.recordWager(tx, { userId, currency: 'USD', amount, weightedAmount: amount, 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('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..63292ab3b 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -90,7 +90,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 +184,78 @@ 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. + */ +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), + }), +]); +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; + export const gamificationContract = { ranks: { get: oc.route({ method: 'GET', path: '/promo/ranks' }).output(PlayerRankSchema), @@ -195,7 +267,27 @@ export const gamificationContract = { ladder: oc.route({ method: 'GET', path: '/promo/ranks/ladder' }).output(RankLadderSchema), }, + streaks: { + get: oc.route({ method: 'GET', path: '/promo/streaks' }).output(PlayerStreakSchema), + leaderboard: oc + .route({ method: 'GET', path: '/promo/streaks/leaderboard' }) + .output(StreakLeaderboardSchema), + }, + 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), diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql b/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql new file mode 100644 index 000000000..0a2995056 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql @@ -0,0 +1,51 @@ +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_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 +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..2fb516a45 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json @@ -0,0 +1,883 @@ +{ + "id": "9fb1c1a4-1336-414e-b47b-895f9f24eb7a", + "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_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..0b1677fd6 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": 1790306558604, + "tag": "0012_bitter_sister_grimm", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index bef505ea6..8dcdc7bcc 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -10,6 +10,8 @@ import { PromoConfigSchema, WAGER_TRACKING, queue, + type WagerTrackingArgs, + type WagerTrackingCommands, } from '@openora/core/contracts'; import { ADMIN_GUARD, @@ -17,6 +19,7 @@ import { EVENT_BUS, createLogger, type CoreTokenCatalog, + type DrizzleTx, type EventBus, type Plugin, type TypedContainer, @@ -24,40 +27,65 @@ import { 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 { 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'); // 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) { + for (const consumer of this.consumers) { + await consumer.recordWager(tx, args); + } + } +} 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); + export default { id: 'gamification', dependsOn: ['exchange-rate', 'audit'], register(ctx) { - ctx.provide(WAGER_TRACKING, rankService); + ctx.provide( + WAGER_TRACKING, + (c) => new CompositeWagerTracking([rankService(c), streakService(c)]), + ); - let payouts: RankPayoutService | null = null; + let rankPayouts: RankPayoutService | null = null; + let streakPayouts: StreakPayoutService | null = null; + let streaks: StreakService | null = null; let events: EventBus | null = null; 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 +94,38 @@ 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 = await streakPayouts.settlePending(); + for (const grant of granted) { + events?.emit('promo.bonus.granted', grant); + } + }, + }); + + 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.routers.add('promo-gamification', (c) => { - payouts = new RankPayoutService( + 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 +133,52 @@ 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, + logger, + ); + streaks = streakService(c); events = c.get(EVENT_BUS); 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')); 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)), 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..027f35777 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -12,14 +12,20 @@ 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'; export function createGamificationRouter({ ranks, admin, + streaks, + streakAdmin, adminGuard, }: { ranks: RankService; admin: RankAdminService; + streaks: StreakService; + streakAdmin: StreakAdminService; adminGuard: AdminGuard; }) { const os = implement(gamificationContract).$context(); @@ -38,7 +44,33 @@ export function createGamificationRouter({ ), }, + 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)), + ), + }, + 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'); diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 3cc12a8c4..38178d312 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,12 @@ import { MONEY_PRECISION, MONEY_SCALE, } from '@openora/core/contracts'; -import type { RankConfig, RankPayoutAnchors, RankPayoutKind } from '../contract/index.js'; +import type { + RankConfig, + RankPayoutAnchors, + RankPayoutKind, + StreakMilestone, +} from '../contract/index.js'; const money = () => decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }); @@ -70,6 +76,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 +209,112 @@ 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; diff --git a/packages/core/src/promo/gamification/seed/index.ts b/packages/core/src/promo/gamification/seed/index.ts index a9e53286b..6765a22c0 100644 --- a/packages/core/src/promo/gamification/seed/index.ts +++ b/packages/core/src/promo/gamification/seed/index.ts @@ -1,6 +1,6 @@ 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 { promoRankConfig, promoRankTier, promoStreakConfig } from '../schema/index.js'; export type RankTierSeed = { key: string; @@ -39,3 +39,13 @@ 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(); +} 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..62baa8ce5 --- /dev/null +++ b/packages/core/src/promo/gamification/service/streak-payout.service.ts @@ -0,0 +1,192 @@ +import { randomInt } from 'node:crypto'; +import { and, asc, eq, isNull } from 'drizzle-orm'; +import type { + BonusGrantCommands, + DomainEventPayload, + PlayEligibilityPort, + Uuid, +} 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'; + +type Granted = DomainEventPayload<'promo.bonus.granted'>; + +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 logger: Logger, + ) {} + + async settlePending(): Promise { + if (!this.grants || !this.eligibility) { + this.logger.warn({}, 'streak payout skipped - bonus grants or play eligibility not bound'); + return []; + } + const [config] = await this.drizzle.db + .select({ milestones: promoStreakConfig.milestones }) + .from(promoStreakConfig); + if (!config) { + return []; + } + 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[] = []; + for (const { id } of owed) { + try { + const paid = await this.drizzle.db.transaction((tx) => + this.settleOne(tx, id, config.milestones), + ); + granted.push(...paid); + } 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; + } + + 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 []; + } + 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 []; + } + const rewards = milestones.find((m) => m.day === row.day)?.rewards ?? []; + const granted: Granted[] = []; + 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; + } + const paid = await this.grantOne(tx, row.userId, reward, sourceRef); + if (paid) { + granted.push(paid); + } + } + await settle('granted'); + return granted; + } + + 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, + }; + } + + 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..413d8db58 --- /dev/null +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -0,0 +1,222 @@ +import { and, desc, eq, gt, sql } from 'drizzle-orm'; +import { user } from '@openora/core/pam/schema/identity'; +import type { + ExchangeRateReader, + Uuid, + WagerTrackingArgs, + WagerTrackingCommands, +} 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. + */ +export class StreakService implements WagerTrackingCommands { + constructor( + private readonly drizzle: DrizzleService, + private readonly rates: ExchangeRateReader, + private readonly logger: Logger, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + if (moneyCompare(args.amount, '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.amount + : await this.rates.convert(args.amount, 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.amount }, + '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)); + } + } + + 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; + } +} From 6b1e630aca136570aa2cca22e384f640182867ca Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:40:29 +0200 Subject: [PATCH 07/28] feat(wallet): add cashback transaction type and own-money stake tracking (BF-568) Adds a `cashback` wallet transaction type for operator-funded real-money credits with no wagering requirement, and a `realAmount` field on WagerTrackingArgs carrying the own-funds portion of a bet's stake so a downstream consumer (rank rakeback) can exclude bonus-funded stake. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../src/contracts/adapters/wager-tracking.ts | 7 + .../core/src/contracts/schemas/wallet-tx.ts | 2 + .../promo/bonus/service/wagering.service.ts | 2 + .../migrations/0023_colossal_doctor_doom.sql | 1 + .../migrations/meta/0023_snapshot.json | 2043 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + 6 files changed, 2062 insertions(+) create mode 100644 packages/core/src/wallet/drizzle/migrations/0023_colossal_doctor_doom.sql create mode 100644 packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json diff --git a/packages/core/src/contracts/adapters/wager-tracking.ts b/packages/core/src/contracts/adapters/wager-tracking.ts index d64799946..800a9b79c 100644 --- a/packages/core/src/contracts/adapters/wager-tracking.ts +++ b/packages/core/src/contracts/adapters/wager-tracking.ts @@ -17,6 +17,13 @@ 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. Rank/streak counters intentionally ignore this and count the full stake (see + * their own doc comments); it exists for a consumer that must not reward money the player never + * risked, such as real-money rakeback. + */ + realAmount: string; context: WagerContext; }; 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/promo/bonus/service/wagering.service.ts b/packages/core/src/promo/bonus/service/wagering.service.ts index c08ce1ef0..f0c1ec0e4 100644 --- a/packages/core/src/promo/bonus/service/wagering.service.ts +++ b/packages/core/src/promo/bonus/service/wagering.service.ts @@ -83,6 +83,7 @@ export class WageringService implements BonusWageringCommands { currency: args.currency, amount: args.stake, weightedAmount: args.stake, + realAmount: args.stake, context: args.context, }); return { @@ -171,6 +172,7 @@ export class WageringService implements BonusWageringCommands { currency: args.currency, amount: args.stake, weightedAmount: weighted, + realAmount: moneySubtract(args.stake, args.fromBonus), context: args.context, }); 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..b45f5af2b --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json @@ -0,0 +1,2043 @@ +{ + "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 } ] } From aa278687cb59827be38608f65a6500edc64a1265 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:40:37 +0200 Subject: [PATCH 08/28] feat(gamification): pay rank rakeback and add a cash streak reward (BF-568) RakebackService credits a qualifying bet's own-money stake at the player's tier rakeback percentage plus any active streak boost, straight to real balance in the bet's own transaction - Confluence "Rank Bonuses" Scenario 3. Rank/streak wagering counters are unchanged. Adds `ranks.lookup`, a public batched rank-badge lookup by user id, and a `cash` streak reward kind that credits real money directly with no wagering requirement, replacing the `bonus`-with-0.01-multiplier workaround. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../src/promo/gamification/contract/index.ts | 33 ++++++- .../core/src/promo/gamification/plugin.ts | 8 +- .../src/promo/gamification/router/index.ts | 3 + .../gamification/service/rakeback.service.ts | 91 +++++++++++++++++++ .../gamification/service/rank.service.ts | 33 ++++++- .../service/streak-payout.service.ts | 35 +++++++ 6 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/promo/gamification/service/rakeback.service.ts diff --git a/packages/core/src/promo/gamification/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index 63292ab3b..dcda964e1 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -189,7 +189,10 @@ export type RankConfig = z.infer; * `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. + * 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 }), @@ -204,6 +207,10 @@ export const StreakRewardSchema = z.discriminatedUnion('kind', [ 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; @@ -256,6 +263,20 @@ export const StreakLeaderboardSchema = z.object({ }); 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; + export const gamificationContract = { ranks: { get: oc.route({ method: 'GET', path: '/promo/ranks' }).output(PlayerRankSchema), @@ -265,6 +286,16 @@ 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: { diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index 8dcdc7bcc..60ae200ac 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -6,6 +6,7 @@ import { JOB_QUEUE, PLATFORM_CONFIG, PLAY_ELIGIBILITY, + WALLET_COMMANDS, WALLET_READER, PromoConfigSchema, WAGER_TRACKING, @@ -24,6 +25,7 @@ import { 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'; @@ -60,13 +62,16 @@ const rankService = (c: TypedContainer) => const streakService = (c: TypedContainer) => new StreakService(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); + export default { id: 'gamification', dependsOn: ['exchange-rate', 'audit'], register(ctx) { ctx.provide( WAGER_TRACKING, - (c) => new CompositeWagerTracking([rankService(c), streakService(c)]), + (c) => new CompositeWagerTracking([rankService(c), rakebackService(c), streakService(c)]), ); let rankPayouts: RankPayoutService | null = null; @@ -138,6 +143,7 @@ export default { c.has(BONUS_GRANTS) ? c.get(BONUS_GRANTS) : undefined, c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, logger, + c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined, ); streaks = streakService(c); events = c.get(EVENT_BUS); diff --git a/packages/core/src/promo/gamification/router/index.ts b/packages/core/src/promo/gamification/router/index.ts index 027f35777..1034b086a 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -42,6 +42,9 @@ 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: { 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..66cf63ca5 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -0,0 +1,91 @@ +import { eq } from 'drizzle-orm'; +import type { + WagerTrackingArgs, + WagerTrackingCommands, + 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. + */ +export class RakebackService implements WagerTrackingCommands { + constructor( + private readonly wallet: WalletCommands | undefined, + private readonly logger: { warn: (context: object, message: string) => void }, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + if (!this.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; + } + const outcome = await this.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'); + } + } +} diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index b0de21433..8d910db6a 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -1,4 +1,4 @@ -import { asc, eq, sql } from 'drizzle-orm'; +import { asc, eq, inArray, sql } from 'drizzle-orm'; import type { AuditWritePort, ExchangeRateReader, @@ -11,7 +11,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, @@ -215,6 +215,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-payout.service.ts b/packages/core/src/promo/gamification/service/streak-payout.service.ts index 62baa8ce5..f004d188a 100644 --- a/packages/core/src/promo/gamification/service/streak-payout.service.ts +++ b/packages/core/src/promo/gamification/service/streak-payout.service.ts @@ -5,6 +5,7 @@ import type { DomainEventPayload, 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'; @@ -44,6 +45,7 @@ export class StreakPayoutService { private readonly grants: BonusGrantCommands | undefined, private readonly eligibility: PlayEligibilityPort | undefined, private readonly logger: Logger, + private readonly wallet?: WalletCommands, ) {} async settlePending(): Promise { @@ -111,6 +113,10 @@ export class StreakPayoutService { await this.applyRakebackBoost(tx, row.userId, reward); continue; } + if (reward.kind === 'cash') { + await this.grantCash(tx, row.userId, reward, sourceRef); + continue; + } const paid = await this.grantOne(tx, row.userId, reward, sourceRef); if (paid) { granted.push(paid); @@ -166,6 +172,35 @@ export class StreakPayoutService { }; } + /** + * `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, + ) { + if (!this.wallet) { + throw new Error('WALLET_COMMANDS is not bound'); + } + const currency = await this.currencyFor(); + const outcome = await this.wallet.credit(tx, { + userId, + amount: reward.amount, + currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-streak', providerRefId: sourceRef }, + }); + if (!outcome.ok) { + this.logger.error({ userId, sourceRef, reason: outcome.reason }, 'streak cash reward failed'); + } + } + private async currencyFor() { const [config] = await this.drizzle.db .select({ currency: promoStreakConfig.currency }) From d4e98e85b6d714a338d0cbe45aa6e0b0929f897d Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:40:42 +0200 Subject: [PATCH 09/28] test(gamification): cover rakeback accrual, cash streak reward, rank lookup (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .changeset/rank-rakeback-payout-engine.md | 22 +++ .../__tests__/rakeback.int.test.ts | 144 ++++++++++++++++++ .../gamification/__tests__/rank.int.test.ts | 30 +++- .../__tests__/streak-payout.int.test.ts | 123 +++++++++++++++ .../gamification/__tests__/streak.int.test.ts | 9 +- 5 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 .changeset/rank-rakeback-payout-engine.md create mode 100644 packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts create mode 100644 packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts diff --git a/.changeset/rank-rakeback-payout-engine.md b/.changeset/rank-rakeback-payout-engine.md new file mode 100644 index 000000000..d3f6d9f13 --- /dev/null +++ b/.changeset/rank-rakeback-payout-engine.md @@ -0,0 +1,22 @@ +--- +'@openora/core': minor +--- + +Rank rakeback now actually pays: `WAGER_TRACKING` gains a `RakebackService` consumer that credits +a qualifying bet's own-money stake, at the player's rank rakeback percentage (tier rate plus any +active streak boost), straight to their real balance in the same transaction as the bet. No house +edge factor and no claim step - rakeback lands on the balance as it accrues. Only the part of a +stake the player's own funds paid for counts: `WAGER_TRACKING`'s `WagerTrackingArgs` gains a +`realAmount` field alongside `amount`, so a bonus-funded stake never earns real-money rakeback on +funds the player never risked. + +A new wallet transaction type, `cashback`, covers this and any other operator-funded real-money +credit that carries no wagering requirement - the daily-streak reward kind `cash` (alongside the +existing `bonus`, `giftDrop` and `rakebackBoost`) uses the same type for a milestone paid straight +to the balance rather than through a bonus grant. + +`gamificationContract.ranks` gains a public `lookup` endpoint - a batched, public-fields-only rank +badge lookup for a set of user ids (tier key and name, nothing wagered or earned), for a chat +avatar or profile card to show another player's real rank instead of a placeholder. + +Needs a migration: `ALTER TYPE wallet_transaction_type ADD VALUE 'cashback'`. 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..f0b0863da --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -0,0 +1,144 @@ +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(); + }); +}); 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..2472b8247 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, }), ); @@ -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..e3277f8b0 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts @@ -0,0 +1,123 @@ +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, + 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 logger = { warn: vi.fn(), error: vi.fn() }; + +const MILESTONES: StreakMilestone[] = [{ day: 3, rewards: [{ kind: 'cash', amount: '5' }] }]; + +const service = () => + new StreakPayoutService( + db.drizzle, + mock({ grant }), + mock({ isRestricted }), + 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' }); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/streak.int.test.ts b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts index 2265f9b83..046ba6810 100644 --- a/packages/core/src/promo/gamification/__tests__/streak.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts @@ -68,7 +68,14 @@ beforeEach(async () => { const record = (userId: string, amount: string, context: WagerContext = CASINO) => db.drizzle.db.transaction((tx) => - streaks.recordWager(tx, { userId, currency: 'USD', amount, weightedAmount: amount, context }), + streaks.recordWager(tx, { + userId, + currency: 'USD', + amount, + weightedAmount: amount, + realAmount: amount, + context, + }), ); describe('recordWager', () => { From f6252b35a25ea8f494d28a9f457ce68802db7b18 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:41:05 +0200 Subject: [PATCH 10/28] chore(wallet): format the cashback migration snapshot (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../migrations/meta/0023_snapshot.json | 91 ++++--------------- 1 file changed, 18 insertions(+), 73 deletions(-) diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json index b45f5af2b..4eb18ec17 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json @@ -60,9 +60,7 @@ "auto_withdrawal_rule_user_id_unique": { "name": "auto_withdrawal_rule_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -107,9 +105,7 @@ "wallet_user_id_unique": { "name": "wallet_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -310,9 +306,7 @@ "wallet_auto_withdrawal_config_singletonKey_unique": { "name": "wallet_auto_withdrawal_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, @@ -392,12 +386,8 @@ "name": "wallet_balance_wallet_id_wallet_id_fk", "tableFrom": "wallet_balance", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -540,12 +530,8 @@ "name": "wallet_bonus_credit_wallet_id_wallet_id_fk", "tableFrom": "wallet_bonus_credit", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -607,9 +593,7 @@ "wallet_bonus_rollover_config_singletonKey_unique": { "name": "wallet_bonus_rollover_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, @@ -1763,12 +1747,8 @@ "name": "wallet_transaction_wallet_id_wallet_id_fk", "tableFrom": "wallet_transaction", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1920,48 +1900,27 @@ "public.wallet_bonus_credit_source_type": { "name": "wallet_bonus_credit_source_type", "schema": "public", - "values": [ - "gift", - "rain" - ] + "values": ["gift", "rain"] }, "public.wallet_bonus_credit_status": { "name": "wallet_bonus_credit_status", "schema": "public", - "values": [ - "active", - "completed" - ] + "values": ["active", "completed"] }, "public.wallet_custody_sweep_status": { "name": "wallet_custody_sweep_status", "schema": "public", - "values": [ - "pending", - "processing", - "completed", - "failed", - "unknown" - ] + "values": ["pending", "processing", "completed", "failed", "unknown"] }, "public.wallet_job_run_status": { "name": "wallet_job_run_status", "schema": "public", - "values": [ - "running", - "completed", - "skipped", - "failed", - "abandoned" - ] + "values": ["running", "completed", "skipped", "failed", "abandoned"] }, "public.wallet_rail": { "name": "wallet_rail", "schema": "public", - "values": [ - "crypto", - "fiat" - ] + "values": ["crypto", "fiat"] }, "public.wallet_reconciliation_finding_kind": { "name": "wallet_reconciliation_finding_kind", @@ -1982,31 +1941,17 @@ "public.wallet_reconciliation_finding_status": { "name": "wallet_reconciliation_finding_status", "schema": "public", - "values": [ - "open", - "resolved" - ] + "values": ["open", "resolved"] }, "public.wallet_transaction_direction": { "name": "wallet_transaction_direction", "schema": "public", - "values": [ - "credit", - "debit" - ] + "values": ["credit", "debit"] }, "public.wallet_transaction_status": { "name": "wallet_transaction_status", "schema": "public", - "values": [ - "pending", - "processing", - "completed", - "failed", - "rejected", - "on_hold", - "cancelled" - ] + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] }, "public.wallet_transaction_type": { "name": "wallet_transaction_type", From 22a58c9b565299da7ee9a518cf2b29a41ad4ce93 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:41:50 +0200 Subject: [PATCH 11/28] test(wallet): classify cashback as a credit in the direction-backfill vocabulary (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../__tests__/wallet-transaction-direction-backfill.test.ts | 1 + 1 file changed, 1 insertion(+) 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', From cb45349cb48025bbfd8785947e6a476f7e3f1f2f Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:42:38 +0200 Subject: [PATCH 12/28] test(testing): add realAmount to e2e fixtures after the WagerTrackingArgs change (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts | 2 ++ packages/testing/src/__tests__/promo-rank-config.e2e.test.ts | 1 + packages/testing/src/__tests__/promo-rank-level-up.e2e.test.ts | 1 + packages/testing/src/__tests__/promo-ranks.e2e.test.ts | 1 + 4 files changed, 5 insertions(+) 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' }, }), ); From dbdc638a71f558d50b21f321eddecae5eaee5230 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 06:52:44 +0200 Subject: [PATCH 13/28] fix(gamification): break a startup circular dependency on WALLET_COMMANDS (BF-568) WALLET_COMMANDS' own factory resolves WAGER_TRACKING transitively through the bonus module's wagering service, so RakebackService resolving WALLET_COMMANDS eagerly at WAGER_TRACKING's own construction time threw "Circular dependency resolving token WALLET_COMMANDS" on boot. It now takes a getWallet thunk and defers the lookup to the first bet. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../gamification/__tests__/rakeback.int.test.ts | 2 +- packages/core/src/promo/gamification/plugin.ts | 2 +- .../promo/gamification/service/rakeback.service.ts | 13 ++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts index f0b0863da..44fe9c9fd 100644 --- a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -62,7 +62,7 @@ const givePlayerTier = async (userId: string, tierKey: string) => { beforeAll(async () => { db = await createTestDb([migrate]); - rakeback = new RakebackService(mock({ credit }), logger); + rakeback = new RakebackService(() => mock({ credit }), logger); }); afterAll(() => db.drop()); diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index 60ae200ac..a8ed3df92 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -63,7 +63,7 @@ const streakService = (c: TypedContainer) => new StreakService(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); + new RakebackService(() => (c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined), logger); export default { id: 'gamification', diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts index 66cf63ca5..b0e2da162 100644 --- a/packages/core/src/promo/gamification/service/rakeback.service.ts +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -33,15 +33,22 @@ const countsToward = (eligibleProducts: readonly string[], product: string) => * * 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 wallet: WalletCommands | undefined, + private readonly getWallet: () => WalletCommands | undefined, private readonly logger: { warn: (context: object, message: string) => void }, ) {} async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { - if (!this.wallet || moneyCompare(args.realAmount, ZERO) <= 0) { + const wallet = this.getWallet(); + if (!wallet || moneyCompare(args.realAmount, ZERO) <= 0) { return; } const [config] = await tx @@ -78,7 +85,7 @@ export class RakebackService implements WagerTrackingCommands { if (moneyCompare(rakeback, ZERO) <= 0) { return; } - const outcome = await this.wallet.credit(tx, { + const outcome = await wallet.credit(tx, { userId: args.userId, amount: rakeback, currency: args.currency, From 7ff7379f282f035246d4868b85914a661cf49842 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 07:29:19 +0200 Subject: [PATCH 14/28] feat(gamification): add wager challenge (leaderboard race) engine Adds a race engine to promo/gamification: an operator-configured window of wagering volume, ranked, with a prize pool split across paid positions. Reuses the module's existing WAGER_TRACKING consumer, admin/audit, payout job, and leaderboard patterns. - Schema: promo_race, promo_race_wager, promo_race_payout, plus a new player.hideUsernameOnLeaderboards preference (pam/profile). - RaceService records real-money wagers into every open, eligible race and serves the player-facing leaderboard (masked usernames, own entry, amount to the next paid position). - RaceAdminService creates/updates a race, prospective-only, blocked once closed. - RacePayoutService closes a race once its window ends, freezes standings, pays every position idempotently, and emits promo.race.won per winner. - promo.race.won wired to an in-app + email notification. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .changeset/wager-challenge-races.md | 11 + packages/core/src/contracts/schemas/events.ts | 10 + packages/core/src/contracts/schemas/mail.ts | 8 + packages/core/src/contracts/schemas/player.ts | 4 + .../notifications/contract/index.ts | 1 + .../src/engagement/notifications/plugin.ts | 22 + .../default-email-template-renderer.ts | 4 + .../migrations/0006_natural_inertia.sql | 1 + .../migrations/meta/0006_snapshot.json | 249 ++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/pam/profile/schema/index.ts | 5 + packages/core/src/pam/shared/player-mapper.ts | 1 + .../__tests__/race-admin.int.test.ts | 127 ++ .../__tests__/race-payout.int.test.ts | 180 +++ .../gamification/__tests__/race.int.test.ts | 245 ++++ .../src/promo/gamification/contract/index.ts | 116 ++ .../drizzle/migrations/0013_broad_reavers.sql | 49 + .../migrations/meta/0013_snapshot.json | 1245 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/gamification/index.ts | 7 + .../core/src/promo/gamification/plugin.ts | 48 +- .../src/promo/gamification/router/index.ts | 51 + .../src/promo/gamification/schema/index.ts | 111 ++ .../service/race-admin.service.ts | 181 +++ .../service/race-payout.service.ts | 149 ++ .../gamification/service/race.service.ts | 233 +++ 26 files changed, 3071 insertions(+), 1 deletion(-) create mode 100644 .changeset/wager-challenge-races.md create mode 100644 packages/core/src/pam/profile/drizzle/migrations/0006_natural_inertia.sql create mode 100644 packages/core/src/pam/profile/drizzle/migrations/meta/0006_snapshot.json create mode 100644 packages/core/src/promo/gamification/__tests__/race-admin.int.test.ts create mode 100644 packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts create mode 100644 packages/core/src/promo/gamification/__tests__/race.int.test.ts create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json create mode 100644 packages/core/src/promo/gamification/service/race-admin.service.ts create mode 100644 packages/core/src/promo/gamification/service/race-payout.service.ts create mode 100644 packages/core/src/promo/gamification/service/race.service.ts diff --git a/.changeset/wager-challenge-races.md b/.changeset/wager-challenge-races.md new file mode 100644 index 000000000..0af0763ee --- /dev/null +++ b/.changeset/wager-challenge-races.md @@ -0,0 +1,11 @@ +--- +'@openora/core': minor +--- + +Wager challenges (leaderboard races) in `promo/gamification`: an operator-configured window of wagering volume, ranked, with a prize pool split across paid positions. + +A race is priced in one currency, with `startAt`/`endAt`, a `prizePool`, and `positions` (`{ position, prize }[]`, prizes summing to at most the pool). `WAGER_TRACKING` gains a fourth consumer alongside the rank ladder, rakeback and streak: every real-money bet in an eligible product (the race's own product list, same convention as the rank ladder and streak) accrues into the player's standing for every race currently open, converted into the race's currency at the rate of the moment. Bonus-funded stake is never counted. + +`GET /promo/races` lists open races; `GET /promo/races/{raceId}` returns the race's own config, a top-3 podium, a capped leaderboard (up to 100 ranked entries total - real pagination is left for a race that outgrows that), the caller's own standing (their true position and wagered total, never affected by their own privacy setting), and how much more they need to wager to reach the next paid position. Every other player's username on the leaderboard is partially masked, or shown as `Incognito` for a player who set the new `hideUsernameOnLeaderboards` profile preference (`PATCH /profile`) - their own row is never masked to themselves, and the setting never touches their standing or prize eligibility. + +`POST /backoffice/promo/races` and `PUT /backoffice/promo/races/{raceId}` create and edit a race, audited like the rank ladder; editing is refused once a race has closed. A short recurring job closes a race once its window ends, freezes final standings (ties broken by whoever reached the total first), pays every position through a direct real-cash credit (idempotent - a retried tick never pays twice), and emits `promo.race.won` per winner, which now sends both an in-app notification and an email. diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 906a00b4f..c5d6df702 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -738,6 +738,16 @@ 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, + }), '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..2b6e5112a 100644 --- a/packages/core/src/contracts/schemas/mail.ts +++ b/packages/core/src/contracts/schemas/mail.ts @@ -26,6 +26,7 @@ export const MAIL_TEMPLATE_KEYS = [ 'emailChangeConfirmation', 'emailChanged', 'securityAntiPhishingCodeChanged', + 'raceWon', ] as const; export type EmailTemplateKey = (typeof MAIL_TEMPLATE_KEYS)[number]; @@ -90,6 +91,12 @@ 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, + }), } as const satisfies Record; export type EmailTemplateData = { @@ -123,6 +130,7 @@ export const MailTemplateSchema = z.discriminatedUnion('key', [ templateVariant('emailChangeConfirmation'), templateVariant('emailChanged'), templateVariant('securityAntiPhishingCodeChanged'), + templateVariant('raceWon'), ]); export type MailTemplate = { 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/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index cd0f7098f..d3b3a42d2 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -11,6 +11,7 @@ export const NOTIFICATION_TYPES = [ 'social.friend_request.accepted', 'promo.bonus.granted', 'promo.bonus.completed', + 'promo.race.won', 'chat.rain.received', 'chat.tip.received', 'chat.gift.claimed', diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index facc76c57..315318343 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -263,6 +263,28 @@ export const notificationEventMap: NotificationMapEntry[] = [ data: { grantId: p.grantId }, })), + 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('chat.user.mentioned', (p) => ({ userId: p.mentionedUserId, type: 'chat.mention', 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..b2eadcbe2 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,10 @@ 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.`, + }), }; 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/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/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..2d1ce2e3a --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts @@ -0,0 +1,180 @@ +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 { 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 logger = { warn: vi.fn(), error: vi.fn() }; + +const POSITIONS: RacePosition[] = [ + { position: 1, prize: '500' }, + { position: 2, prize: '250' }, +]; + +const service = () => + new RacePayoutService( + db.drizzle, + mock({ isRestricted }), + mock({ credit }), + 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(); + }); +}); 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/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index dcda964e1..268b01abd 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'; @@ -277,6 +278,90 @@ export const RankLookupEntrySchema = z.object({ }); 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; + export const gamificationContract = { ranks: { get: oc.route({ method: 'GET', path: '/promo/ranks' }).output(PlayerRankSchema), @@ -305,6 +390,16 @@ export const gamificationContract = { .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), + }, + admin: { streaks: { config: { @@ -338,5 +433,26 @@ 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), + }, }, }; diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql b/packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql new file mode 100644 index 000000000..e31d9a7d7 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql @@ -0,0 +1,49 @@ +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 +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"); \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..e7105adcf --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1245 @@ +{ + "id": "a6da3aa8-11d2-4708-b3fa-3c1a4e6b1523", + "prevId": "9fb1c1a4-1336-414e-b47b-895f9f24eb7a", + "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_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 0b1677fd6..6b72a36a7 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1790306558604, "tag": "0012_bitter_sister_grimm", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1790313747654, + "tag": "0013_broad_reavers", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/index.ts b/packages/core/src/promo/gamification/index.ts index dfc115985..3456334af 100644 --- a/packages/core/src/promo/gamification/index.ts +++ b/packages/core/src/promo/gamification/index.ts @@ -9,4 +9,11 @@ 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 { createGamificationRouter } from './router/index.js'; diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index a8ed3df92..4f5d4a18d 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -32,6 +32,9 @@ 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 { createGamificationRouter } from './router/index.js'; import { RankPayoutKindSchema } from './contract/index.js'; @@ -40,6 +43,10 @@ const logger = createLogger('promo-gamification'); 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'); +// 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 * * * *'; // The cron tick carries only which payout to run; what is owed is read from the database. const RankPayoutJobSchema = z.object({ kind: RankPayoutKindSchema }); @@ -62,6 +69,9 @@ const rankService = (c: TypedContainer) => 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); @@ -71,11 +81,18 @@ export default { register(ctx) { ctx.provide( WAGER_TRACKING, - (c) => new CompositeWagerTracking([rankService(c), rakebackService(c), streakService(c)]), + (c) => + new CompositeWagerTracking([ + rankService(c), + rakebackService(c), + streakService(c), + raceService(c), + ]), ); let rankPayouts: RankPayoutService | null = null; let streakPayouts: StreakPayoutService | null = null; + let racePayouts: RacePayoutService | null = null; let streaks: StreakService | null = null; let events: EventBus | null = null; @@ -129,6 +146,24 @@ export default { }, }); + 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); + } + }, + }); + ctx.routers.add('promo-gamification', (c) => { rankPayouts = new RankPayoutService( c.get(DRIZZLE), @@ -145,6 +180,12 @@ export default { 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, + logger, + ); streaks = streakService(c); events = c.get(EVENT_BUS); const schedule = PromoConfigSchema.parse( @@ -179,12 +220,17 @@ export default { { 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')); 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)), 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 1034b086a..a543476c0 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -14,18 +14,28 @@ import { 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'; export function createGamificationRouter({ ranks, admin, streaks, streakAdmin, + races, + raceAdmin, adminGuard, }: { ranks: RankService; admin: RankAdminService; streaks: StreakService; streakAdmin: StreakAdminService; + races: RaceService; + raceAdmin: RaceAdminService; adminGuard: AdminGuard; }) { const os = implement(gamificationContract).$context(); @@ -59,6 +69,16 @@ export function createGamificationRouter({ ), }, + 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)), + ), + ), + }, + admin: { streaks: { config: { @@ -106,6 +126,37 @@ 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), + ); + }), + }, }, }); } diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 38178d312..1060cfed3 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -20,6 +20,8 @@ import { MONEY_SCALE, } from '@openora/core/contracts'; import type { + RaceEligibleProducts, + RacePositions, RankConfig, RankPayoutAnchors, RankPayoutKind, @@ -318,3 +320,112 @@ export const promoStreakMilestoneGrant = pgTable( ); 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; 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..e77b623a2 --- /dev/null +++ b/packages/core/src/promo/gamification/service/race-payout.service.ts @@ -0,0 +1,149 @@ +import { and, asc, desc, eq, isNull, lte } from 'drizzle-orm'; +import type { PlayEligibilityPort, Uuid, WalletCommands } from '@openora/core/contracts'; +import type { DrizzleService, DrizzleTx } from '@openora/core/server'; +import { promoRace, promoRacePayout, promoRaceWager } from '../schema/index.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; + amount: string; + currency: string; +}; + +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 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 outcome = (await this.eligibility?.isRestricted(standing.userId)) ?? true; + const grantId: string | null = null; + if (!outcome && this.wallet) { + const sourceRef = `race-payout:${raceId}:${standing.userId}`; + const credited = await this.wallet.credit(tx, { + userId: standing.userId, + amount: position.prize, + currency: race.currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-race', providerRefId: sourceRef }, + }); + if (!credited.ok) { + this.logger.error( + { userId: standing.userId, raceId, reason: credited.reason }, + 'race prize credit failed', + ); + } + } + await tx.insert(promoRacePayout).values({ + raceId, + userId: standing.userId, + position: index + 1, + amount: position.prize, + currency: race.currency, + grantId, + outcome: outcome ? 'restricted' : 'granted', + }); + if (!outcome) { + won.push({ + userId: standing.userId, + raceId, + raceName: race.name, + position: index + 1, + amount: position.prize, + currency: race.currency, + }); + } + } + + 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..e2447a38f --- /dev/null +++ b/packages/core/src/promo/gamification/service/race.service.ts @@ -0,0 +1,233 @@ +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, +} 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) { + 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()`, + }, + }); + } + } + + 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); + } +} From 4fdd876a84e23dda48df1994bab5769e1ac704f0 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 08:17:29 +0200 Subject: [PATCH 15/28] feat(gamification): add Rank Challenge - a race-to-threshold ladder with one winner per tier Each tier has a lifetime real-money wagering threshold; the first player to cross it wins its prize (cash, a physical item, or both), once. Claims are detected inline on the same wager-tracking path rank/rakeback/streak/race use, guarded against concurrent double-wins by a unique index on the claim's tier. Settlement (cash credit, win event) runs on a short payout tick, mirroring the rank level-up and streak milestone pattern. Adds an operator fulfilment queue for physical prizes and a prospective-only ladder editor. --- .changeset/rank-challenge-mechanic.md | 22 + packages/core/src/contracts/schemas/events.ts | 11 + packages/core/src/contracts/schemas/mail.ts | 8 + .../notifications/contract/index.ts | 1 + .../src/engagement/notifications/plugin.ts | 36 + .../default-email-template-renderer.ts | 12 + .../__tests__/rank-challenge.int.test.ts | 302 ++++ .../src/promo/gamification/contract/index.ts | 136 ++ .../migrations/0014_rank_challenge.sql | 47 + .../migrations/meta/0014_snapshot.json | 1563 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/gamification/index.ts | 9 + .../core/src/promo/gamification/plugin.ts | 46 + .../src/promo/gamification/router/index.ts | 55 + .../src/promo/gamification/schema/index.ts | 100 ++ .../core/src/promo/gamification/seed/index.ts | 46 +- .../service/rank-challenge-admin.service.ts | 274 +++ .../service/rank-challenge-payout.service.ts | 149 ++ .../service/rank-challenge.service.ts | 236 +++ 19 files changed, 3059 insertions(+), 1 deletion(-) create mode 100644 .changeset/rank-challenge-mechanic.md create mode 100644 packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json create mode 100644 packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts create mode 100644 packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts create mode 100644 packages/core/src/promo/gamification/service/rank-challenge.service.ts diff --git a/.changeset/rank-challenge-mechanic.md b/.changeset/rank-challenge-mechanic.md new file mode 100644 index 000000000..fe7382550 --- /dev/null +++ b/.changeset/rank-challenge-mechanic.md @@ -0,0 +1,22 @@ +--- +'@openora/core': minor +--- + +Adds a Rank Challenge mechanic to the gamification module: a ladder of tiers, each with a +lifetime real-money wagering threshold, where the first player to cross a tier's threshold wins +its prize, once. A tier's prize may be a cash amount, a physical item, or both; cash is credited +automatically once a claim settles, physical prizes go to a new operator-facing fulfilment queue +(list pending, mark fulfilled with a note, audited). + +New player-facing reads: `rankChallenge.get` (progress toward the next unclaimed tier, a top-5 +leaderboard, the player's own position) and `rankChallenge.ladder` (public tier list with winner +info). New admin routes under `admin.rankChallenge`: replace the ladder as one set (prospective +only - a claim snapshots its own prize at the moment it is won, so editing a tier afterward never +changes what a past winner was granted), list every claim, and run the fulfilment queue. + +Concurrency: two players crossing the same tier at once resolve to exactly one winner via a +unique index on the claim's tier, checked through `onConflictDoNothing` plus a `.returning()` +check rather than a pre-check select, so there is no TOCTOU gap. + +New domain event `promo.rankChallenge.won` and notification type (in-app + email), mirroring +`promo.race.won`. New `seedRankChallengeLadder` seed helper, mirroring `seedRankLadder`. diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index c5d6df702..8a9b4563a 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -748,6 +748,17 @@ export const domainEventSchemas = { 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.rankChallenge.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 2b6e5112a..b4f136315 100644 --- a/packages/core/src/contracts/schemas/mail.ts +++ b/packages/core/src/contracts/schemas/mail.ts @@ -27,6 +27,7 @@ export const MAIL_TEMPLATE_KEYS = [ 'emailChanged', 'securityAntiPhishingCodeChanged', 'raceWon', + 'rankChallengeWon', ] as const; export type EmailTemplateKey = (typeof MAIL_TEMPLATE_KEYS)[number]; @@ -97,6 +98,12 @@ export const EmailTemplateDataSchemas = { amount: MoneyAmountSchema, currency: CurrencyTickerSchema, }), + rankChallengeWon: z.object({ + tierName: z.string(), + cashAmount: MoneyAmountSchema.nullable(), + physicalItem: z.string().nullable(), + currency: CurrencyTickerSchema, + }), } as const satisfies Record; export type EmailTemplateData = { @@ -131,6 +138,7 @@ export const MailTemplateSchema = z.discriminatedUnion('key', [ templateVariant('emailChanged'), templateVariant('securityAntiPhishingCodeChanged'), templateVariant('raceWon'), + templateVariant('rankChallengeWon'), ]); export type MailTemplate = { diff --git a/packages/core/src/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index d3b3a42d2..9e2f86f2a 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -12,6 +12,7 @@ export const NOTIFICATION_TYPES = [ 'promo.bonus.granted', 'promo.bonus.completed', 'promo.race.won', + 'promo.rankChallenge.won', 'chat.rain.received', 'chat.tip.received', 'chat.gift.claimed', diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 315318343..ef4aea820 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'); @@ -285,6 +299,28 @@ export const notificationEventMap: NotificationMapEntry[] = [ }, ), + mapEvent( + 'promo.rankChallenge.won', + (p) => ({ + userId: p.userId, + type: 'promo.rankChallenge.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, type: 'chat.mention', 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 b2eadcbe2..7a733a9c2 100644 --- a/packages/core/src/mail/adapters/default-email-template-renderer.ts +++ b/packages/core/src/mail/adapters/default-email-template-renderer.ts @@ -184,6 +184,18 @@ const PLAIN_EMAIL_TEMPLATES: { [K in EmailTemplateKey]: PlainTemplate } = { 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.`, + }; + }, }; const renderDefaultEmail = ( 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..4e4833906 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +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); + +const payoutService = () => + new RankChallengePayoutService( + db.drizzle, + mock({ isRestricted }), + mock({ credit }), + 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('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 claimId = queue[0]?.tierId + ? ( + await db.drizzle.db + .select({ id: promoRankChallengeClaim.id }) + .from(promoRankChallengeClaim) + .where(eq(promoRankChallengeClaim.tierId, queue[0].tierId)) + )[0]?.id + : undefined; + if (!claimId) { + throw new Error('claim not found'); + } + + const fulfilled = await adminService.markFulfilled(randomUUID(), claimId, 'Shipped via FedEx'); + + expect(fulfilled.physicalFulfillmentNote).toBe('Shipped via FedEx'); + expect(await adminService.listFulfilmentQueue()).toHaveLength(0); + }); +}); diff --git a/packages/core/src/promo/gamification/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index 268b01abd..e5619484b 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -362,6 +362,101 @@ export const RaceForPlayerSchema = z.object({ }); 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; + +export const MarkRankChallengeFulfilledInputSchema = z.object({ + claimId: 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), @@ -400,6 +495,17 @@ export const gamificationContract = { .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: { @@ -454,5 +560,35 @@ export const gamificationContract = { .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/{claimId}' }) + .input(MarkRankChallengeFulfilledInputSchema) + .output(RankChallengeClaimSchema), + }, + }, }, }; diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql b/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql new file mode 100644 index 000000000..804619b9f --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql @@ -0,0 +1,47 @@ +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 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; \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json new file mode 100644 index 000000000..6d9f8a796 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json @@ -0,0 +1,1563 @@ +{ + "id": "27f37f05-5156-442a-bd97-5bdba022f997", + "prevId": "a6da3aa8-11d2-4708-b3fa-3c1a4e6b1523", + "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 6b72a36a7..03aca2a86 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1790313747654, "tag": "0013_broad_reavers", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1790316717848, + "tag": "0014_rank_challenge", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/index.ts b/packages/core/src/promo/gamification/index.ts index 3456334af..a4fa2d1a4 100644 --- a/packages/core/src/promo/gamification/index.ts +++ b/packages/core/src/promo/gamification/index.ts @@ -16,4 +16,13 @@ export { 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 4f5d4a18d..70fee74c5 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -35,6 +35,9 @@ 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'; @@ -44,9 +47,13 @@ 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 RankPayoutJobSchema = z.object({ kind: RankPayoutKindSchema }); @@ -75,6 +82,9 @@ const raceService = (c: TypedContainer) => 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'], @@ -87,12 +97,14 @@ export default { rakebackService(c), streakService(c), raceService(c), + rankChallengeService(c), ]), ); 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; @@ -164,6 +176,23 @@ export default { }, }); + 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.rankChallenge.won', win); + } + }, + }); + ctx.routers.add('promo-gamification', (c) => { rankPayouts = new RankPayoutService( c.get(DRIZZLE), @@ -186,6 +215,13 @@ export default { c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined, 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(AUDIT_WRITER), + logger, + ); streaks = streakService(c); events = c.get(EVENT_BUS); const schedule = PromoConfigSchema.parse( @@ -223,6 +259,14 @@ export default { 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), @@ -231,6 +275,8 @@ export default { 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 a543476c0..039467110 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -20,6 +20,11 @@ import { 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, @@ -28,6 +33,8 @@ export function createGamificationRouter({ streakAdmin, races, raceAdmin, + rankChallenge, + rankChallengeAdmin, adminGuard, }: { ranks: RankService; @@ -36,6 +43,8 @@ export function createGamificationRouter({ streakAdmin: StreakAdminService; races: RaceService; raceAdmin: RaceAdminService; + rankChallenge: RankChallengeService; + rankChallengeAdmin: RankChallengeAdminService; adminGuard: AdminGuard; }) { const os = implement(gamificationContract).$context(); @@ -79,6 +88,15 @@ export function createGamificationRouter({ ), }, + 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: { @@ -157,6 +175,43 @@ export function createGamificationRouter({ ); }), }, + + 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.claimId, input.note); + }, + ), + }, + }, }, }); } diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 1060cfed3..864874c45 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -429,3 +429,103 @@ export const promoRacePayout = pgTable( ); 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 6765a22c0..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, StreakConfig } from '../contract/index.js'; -import { promoRankConfig, promoRankTier, promoStreakConfig } from '../schema/index.js'; +import { + promoRankChallengeTier, + promoRankConfig, + promoRankTier, + promoStreakConfig, +} from '../schema/index.js'; export type RankTierSeed = { key: string; @@ -49,3 +54,42 @@ export type StreakSeed = StreakConfig; 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/rank-challenge-admin.service.ts b/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts new file mode 100644 index 000000000..71a4d1212 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts @@ -0,0 +1,274 @@ +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); + } + + async markFulfilled(adminId: Uuid, claimId: Uuid, note: string): Promise { + return this.drizzle.db.transaction(async (tx) => { + const [claim] = await tx + .select() + .from(promoRankChallengeClaim) + .where(eq(promoRankChallengeClaim.id, claimId)) + .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.id, claimId)); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.rankChallenge.fulfilled', + resourceType: 'promo_rank_challenge_claim', + resourceId: claimId, + 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.id, claimId)); + 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..dbc51a89b --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts @@ -0,0 +1,149 @@ +import { asc, eq, isNull } from 'drizzle-orm'; +import type { + AuditWritePort, + PlayEligibilityPort, + Uuid, + WalletCommands, +} from '@openora/core/contracts'; +import type { DrizzleService, DrizzleTx } from '@openora/core/server'; +import { promoRankChallengeClaim, promoRankChallengeTier } from '../schema/index.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; + cashAmount: string | null; + physicalItem: string | null; + currency: string; +}; + +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 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; + const cashGrantId: string | null = null; + if (claim.cashAmount !== null && !restricted && this.wallet) { + const sourceRef = `rank-challenge-payout:${claim.tierId}:${claim.userId}`; + const credited = await this.wallet.credit(tx, { + userId: claim.userId, + amount: claim.cashAmount, + currency: claim.currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-rank-challenge', providerRefId: sourceRef }, + }); + if (!credited.ok) { + this.logger.error( + { userId: claim.userId, tierId: claim.tierId, reason: credited.reason }, + 'rank challenge cash credit failed', + ); + } + } else if (claim.cashAmount !== null && restricted) { + this.logger.warn( + { userId: claim.userId, tierId: claim.tierId }, + 'rank challenge cash withheld - player restricted', + ); + } + + await tx + .update(promoRankChallengeClaim) + .set({ settledAt: new Date(), outcome: restricted ? 'restricted' : 'granted', cashGrantId }) + .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: claim.cashAmount, + physicalItem: claim.physicalItem, + currency: claim.currency, + }; + } +} 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..756cc1cd2 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge.service.ts @@ -0,0 +1,236 @@ +import { asc, desc, eq, gt, sql } from 'drizzle-orm'; +import { user } from '@openora/core/pam/schema/identity'; +import type { + ExchangeRateReader, + Uuid, + WagerTrackingArgs, + WagerTrackingCommands, +} 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) { + 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; + } + } + + /** 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, + }; + } +} From 12e19be190d81ab1e4bf47d7bab05276ecad243a Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 08:48:32 +0200 Subject: [PATCH 16/28] fix(gamification): key rank challenge fulfilment by tierId, not an unexposed claim row id MarkRankChallengeFulfilledInputSchema took a claimId the API's own RankChallengeClaimSchema never returns, so no caller could actually supply one. promo_rank_challenge_claim.tierId is unique per claim and is already on every claim/queue row the admin UI reads - fulfilment now keys off that instead. --- .../__tests__/rank-challenge.int.test.ts | 14 +++----------- .../core/src/promo/gamification/contract/index.ts | 7 +++++-- .../core/src/promo/gamification/router/index.ts | 2 +- .../service/rank-challenge-admin.service.ts | 15 ++++++++++----- 4 files changed, 19 insertions(+), 19 deletions(-) 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 index 4e4833906..bdb683d37 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { eq } from 'drizzle-orm'; import { createTestDb, seedPlayerWithUser, type TestDb } from '@openora/core/testing'; import { mock, makeAuditWriter } from '../../../testing/mock.js'; import type { @@ -282,19 +281,12 @@ describe('the fulfilment queue', () => { const queue = await adminService.listFulfilmentQueue(); expect(queue).toHaveLength(1); - const claimId = queue[0]?.tierId - ? ( - await db.drizzle.db - .select({ id: promoRankChallengeClaim.id }) - .from(promoRankChallengeClaim) - .where(eq(promoRankChallengeClaim.tierId, queue[0].tierId)) - )[0]?.id - : undefined; - if (!claimId) { + const tierId = queue[0]?.tierId; + if (!tierId) { throw new Error('claim not found'); } - const fulfilled = await adminService.markFulfilled(randomUUID(), claimId, 'Shipped via FedEx'); + 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/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index e5619484b..ecf591b2b 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -451,8 +451,11 @@ export const SetRankChallengeLadderInputSchema = z.object({ }); 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({ - claimId: UuidSchema, + tierId: UuidSchema, note: z.string().min(1).max(1000), }); export type MarkRankChallengeFulfilledInput = z.infer; @@ -585,7 +588,7 @@ export const gamificationContract = { .output(z.array(RankChallengeClaimSchema)), markFulfilled: oc - .route({ method: 'POST', path: '/backoffice/promo/rank-challenge/fulfilment/{claimId}' }) + .route({ method: 'POST', path: '/backoffice/promo/rank-challenge/fulfilment/{tierId}' }) .input(MarkRankChallengeFulfilledInputSchema) .output(RankChallengeClaimSchema), }, diff --git a/packages/core/src/promo/gamification/router/index.ts b/packages/core/src/promo/gamification/router/index.ts index 039467110..fc30fc538 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -207,7 +207,7 @@ export function createGamificationRouter({ markFulfilled: os.admin.rankChallenge.fulfilment.markFulfilled.handler( async ({ input, context }) => { const { userId } = await adminGuard.assert(context, 'bonus', 'update'); - return rankChallengeAdmin.markFulfilled(userId, input.claimId, input.note); + return rankChallengeAdmin.markFulfilled(userId, input.tierId, input.note); }, ), }, 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 index 71a4d1212..3b1bd243f 100644 --- a/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts +++ b/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts @@ -219,12 +219,17 @@ export class RankChallengeAdminService { return rows.map(toClaim); } - async markFulfilled(adminId: Uuid, claimId: Uuid, note: string): Promise { + /** + * 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.id, claimId)) + .where(eq(promoRankChallengeClaim.tierId, tierId)) .for('update'); if (!claim) { throw new Error('rank challenge claim not found'); @@ -236,13 +241,13 @@ export class RankChallengeAdminService { physicalFulfilledBy: adminId, physicalFulfillmentNote: note, }) - .where(eq(promoRankChallengeClaim.id, claimId)); + .where(eq(promoRankChallengeClaim.tierId, tierId)); await this.audit.recordInTransaction(tx, { actorId: adminId, actorType: 'admin', action: 'promo.rankChallenge.fulfilled', resourceType: 'promo_rank_challenge_claim', - resourceId: claimId, + resourceId: tierId, before: { physicalFulfilledAt: null }, after: { physicalFulfilledAt: new Date().toISOString(), note }, }); @@ -264,7 +269,7 @@ export class RankChallengeAdminService { eq(promoRankChallengeTier.id, promoRankChallengeClaim.tierId), ) .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)) - .where(eq(promoRankChallengeClaim.id, claimId)); + .where(eq(promoRankChallengeClaim.tierId, tierId)); if (!row) { throw new Error('rank challenge claim vanished after update'); } From f5a3de203484ff2dbfea2e6b252362a29f44df8c Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 08:54:03 +0200 Subject: [PATCH 17/28] chore(gamification): format the rank challenge migration snapshot --- .../migrations/meta/0014_snapshot.json | 56 +++++-------------- 1 file changed, 14 insertions(+), 42 deletions(-) diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json index 6d9f8a796..aecc5f7fc 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json @@ -79,12 +79,8 @@ "name": "promo_player_rank_tier_id_promo_rank_tier_id_fk", "tableFrom": "promo_player_rank", "tableTo": "promo_rank_tier", - "columnsFrom": [ - "tier_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tier_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -94,9 +90,7 @@ "promo_player_rank_userId_unique": { "name": "promo_player_rank_userId_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -167,9 +161,7 @@ "promo_player_streak_userId_unique": { "name": "promo_player_streak_userId_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -412,12 +404,8 @@ "name": "promo_race_payout_race_id_promo_race_id_fk", "tableFrom": "promo_race_payout", "tableTo": "promo_race", - "columnsFrom": [ - "race_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["race_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -530,12 +518,8 @@ "name": "promo_race_wager_race_id_promo_race_id_fk", "tableFrom": "promo_race_wager", "tableTo": "promo_race", - "columnsFrom": [ - "race_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["race_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -767,16 +751,12 @@ "promo_rank_challenge_tier_key_unique": { "name": "promo_rank_challenge_tier_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] }, "promo_rank_challenge_tier_position_unique": { "name": "promo_rank_challenge_tier_position_unique", "nullsNotDistinct": false, - "columns": [ - "position" - ] + "columns": ["position"] } }, "policies": {}, @@ -833,9 +813,7 @@ "promo_rank_challenge_wager_userId_unique": { "name": "promo_rank_challenge_wager_userId_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -947,9 +925,7 @@ "promo_rank_config_singletonKey_unique": { "name": "promo_rank_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, @@ -1273,9 +1249,7 @@ "promo_rank_tier_key_unique": { "name": "promo_rank_tier_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -1366,9 +1340,7 @@ "promo_streak_config_singletonKey_unique": { "name": "promo_streak_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, From e3ced7985d2c3d0ddf79ae390d2726ae2344041e Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 09:19:31 +0200 Subject: [PATCH 18/28] fix(gamification): kebab-case the rank challenge win event topic promo.rankChallenge.won broke the event catalog's own namespacing convention (each dot-segment must be lowercase-kebab), caught by the existing generic contract test. Renamed to promo.rank-challenge.won across the emitter, the notification contract/plugin and the changeset describing the feature. --- .changeset/rank-challenge-mechanic.md | 2 +- packages/core/src/contracts/schemas/events.ts | 2 +- packages/core/src/engagement/notifications/contract/index.ts | 2 +- packages/core/src/engagement/notifications/plugin.ts | 4 ++-- packages/core/src/promo/gamification/plugin.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/rank-challenge-mechanic.md b/.changeset/rank-challenge-mechanic.md index fe7382550..a2d9b6d0c 100644 --- a/.changeset/rank-challenge-mechanic.md +++ b/.changeset/rank-challenge-mechanic.md @@ -18,5 +18,5 @@ Concurrency: two players crossing the same tier at once resolve to exactly one w unique index on the claim's tier, checked through `onConflictDoNothing` plus a `.returning()` check rather than a pre-check select, so there is no TOCTOU gap. -New domain event `promo.rankChallenge.won` and notification type (in-app + email), mirroring +New domain event `promo.rank-challenge.won` and notification type (in-app + email), mirroring `promo.race.won`. New `seedRankChallengeLadder` seed helper, mirroring `seedRankLadder`. diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 8a9b4563a..6effe37be 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -750,7 +750,7 @@ export const domainEventSchemas = { }), // 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.rankChallenge.won': z.object({ + 'promo.rank-challenge.won': z.object({ userId: UuidSchema, tierId: UuidSchema, tierKey: z.string(), diff --git a/packages/core/src/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index 9e2f86f2a..61531927e 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -12,7 +12,7 @@ export const NOTIFICATION_TYPES = [ 'promo.bonus.granted', 'promo.bonus.completed', 'promo.race.won', - 'promo.rankChallenge.won', + 'promo.rank-challenge.won', 'chat.rain.received', 'chat.tip.received', 'chat.gift.claimed', diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index ef4aea820..0b704f39d 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -300,10 +300,10 @@ export const notificationEventMap: NotificationMapEntry[] = [ ), mapEvent( - 'promo.rankChallenge.won', + 'promo.rank-challenge.won', (p) => ({ userId: p.userId, - type: 'promo.rankChallenge.won', + 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 }, diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index 70fee74c5..848f5943a 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -188,7 +188,7 @@ export default { // 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.rankChallenge.won', win); + events?.emit('promo.rank-challenge.won', win); } }, }); From d16056f2b4a00223e6f1495a16997bedcf09c092 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 09:53:10 +0200 Subject: [PATCH 19/28] fix(bonus): forfeit active bonuses on cooling-off, not just self-exclusion The bonus plugin already forfeited every active grant the instant a player self-excluded or closed their account, but a cooling-off period - a lighter, time-boxed responsible-gambling control - left bonus funds live. Adds `cooling_off` to the forfeit-reason vocabulary and subscribes the same immediate forfeit path to `rg.cooling_off.activated`. --- .changeset/bonus-forfeit-on-cooling-off.md | 5 + packages/core/src/contracts/schemas/promo.ts | 1 + .../__tests__/grant-lifecycle.int.test.ts | 12 + .../migrations/0009_amazing_forgotten_one.sql | 1 + .../migrations/meta/0009_snapshot.json | 968 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/bonus/plugin.ts | 18 +- 7 files changed, 1008 insertions(+), 4 deletions(-) create mode 100644 .changeset/bonus-forfeit-on-cooling-off.md create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json diff --git a/.changeset/bonus-forfeit-on-cooling-off.md b/.changeset/bonus-forfeit-on-cooling-off.md new file mode 100644 index 000000000..3231e0efc --- /dev/null +++ b/.changeset/bonus-forfeit-on-cooling-off.md @@ -0,0 +1,5 @@ +--- +'@openora/core': minor +--- + +A player entering a cooling-off period now forfeits every active bonus immediately, the same as a self-exclusion or account closure. `BONUS_FORFEIT_REASONS` gains `cooling_off` (new enum value on `promo_forfeit_reason`), and the bonus plugin now also subscribes to `rg.cooling_off.activated`. diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index f3deb895d..967e34556 100644 --- a/packages/core/src/contracts/schemas/promo.ts +++ b/packages/core/src/contracts/schemas/promo.ts @@ -31,6 +31,7 @@ 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', 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..862367573 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() }); diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql b/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql new file mode 100644 index 000000000..0f9980d56 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'cooling_off' BEFORE 'account_closed'; \ 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..4f9e6ae95 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json @@ -0,0 +1,968 @@ +{ + "id": "6dfe7e38-f784-416c-8162-161bbf9460e3", + "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" + ] + }, + "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"] + }, + "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..15d2fa6a9 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": 1790321212162, + "tag": "0009_amazing_forgotten_one", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index 5310e7847..aeefa2054 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -177,12 +177,18 @@ 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 or closed the account, 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', + >( topic: K, - reason: 'self_exclusion' | 'account_closed', + reason: 'self_exclusion' | 'cooling_off' | 'account_closed', ) => (payload: unknown) => { const parsed = domainEventSchemas[topic].safeParse(payload); @@ -229,6 +235,10 @@ 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'), From 05fb1ae7acdfaec88fb52cbe6eb6a54953f0d09a Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 09:58:12 +0200 Subject: [PATCH 20/28] fix(bonus): allow a system actor to forfeit a single named grant (BF-589) GrantLifecycleService.forfeit() required an admin actor, so nothing but the admin route could take away one specific grant. A scheduled job (the Activity Bonus daily-wager check) needs exactly that: forfeit one grant by id with no admin session to assert. Make actor optional, reusing the system-actor path close() already supported for forfeitAllFor(). Adds the terms_breach forfeit reason for offer-terms breaches a job detects. --- .changeset/bonus-system-forfeit.md | 5 + packages/core/src/contracts/schemas/promo.ts | 3 + .../__tests__/grant-lifecycle.int.test.ts | 44 + .../migrations/0010_narrow_ricochet.sql | 1 + .../migrations/meta/0010_snapshot.json | 1026 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../bonus/service/grant-lifecycle.service.ts | 21 +- 7 files changed, 1102 insertions(+), 5 deletions(-) create mode 100644 .changeset/bonus-system-forfeit.md create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json diff --git a/.changeset/bonus-system-forfeit.md b/.changeset/bonus-system-forfeit.md new file mode 100644 index 000000000..d7100e5e6 --- /dev/null +++ b/.changeset/bonus-system-forfeit.md @@ -0,0 +1,5 @@ +--- +'@openora/core': minor +--- + +`GrantLifecycleService.forfeit()` now takes an optional actor instead of a required one, so a scheduled job can forfeit a single named grant (an Activity Bonus that missed a required wagering day, for example) with no admin session to assert - the same system-actor path `forfeitAllFor` already supported. `BONUS_FORFEIT_REASONS` gains `terms_breach` (new enum value on `promo_forfeit_reason`) for offer-terms breaches a job detects rather than an admin or an RG event. diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index 967e34556..eee53619d 100644 --- a/packages/core/src/contracts/schemas/promo.ts +++ b/packages/core/src/contracts/schemas/promo.ts @@ -36,6 +36,9 @@ export const BONUS_FORFEIT_REASONS = [ '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/promo/bonus/__tests__/grant-lifecycle.int.test.ts b/packages/core/src/promo/bonus/__tests__/grant-lifecycle.int.test.ts index 862367573..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 @@ -275,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/drizzle/migrations/0010_narrow_ricochet.sql b/packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql new file mode 100644 index 000000000..05ce915f5 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'terms_breach'; \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 000000000..ec0f01512 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,1026 @@ +{ + "id": "a4b0bd27-e144-4f5a-8b5d-abf4f1c87c0e", + "prevId": "6dfe7e38-f784-416c-8162-161bbf9460e3", + "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" + ] + }, + "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 15d2fa6a9..8d0adc915 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790321212162, "tag": "0009_amazing_forgotten_one", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1790321212163, + "tag": "0010_narrow_ricochet", + "breakpoints": true } ] } 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. From fb3b95a4b26cdd3da59281a6b09d677d12d99e85 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 11:20:29 +0200 Subject: [PATCH 21/28] fix(bonus): add BONUS_LIFECYCLE, cashback source, bonusUnlocked email (BF-589) A scheduled job in the consumer repo needs to forfeit one named grant with no admin session to assert (the shape forfeitAllFor cannot give it) and to credit a bonus off a computed net loss rather than a deposit. Adds BONUS_LIFECYCLE.forfeit as a thin command port over GrantLifecycleService.forfeit, and 'cashback' to BONUS_GRANT_SOURCES so that grant is not misfiled as 'deposit' or 'manual'. Also wires an email (bonusUnlocked) alongside the existing in-app promo.bonus.completed notification, matching the raceWon/rankChallengeWon pattern, and extends PromoOfferRulesSchema (jsonb, no migration) with two optional operator-tunable fields - freeSpins and periodDays - for offer mechanics core has no dedicated grant shape for yet. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- ...onus-cashback-source-and-unlocked-email.md | 14 + .../src/contracts/adapters/bonus-lifecycle.ts | 26 + packages/core/src/contracts/adapters/index.ts | 3 + packages/core/src/contracts/schemas/mail.ts | 6 + packages/core/src/contracts/schemas/promo.ts | 4 + .../__tests__/notification-event-map.test.ts | 16 + .../src/engagement/notifications/plugin.ts | 23 +- .../default-email-template-renderer.test.ts | 1 + .../default-email-template-renderer.ts | 4 + .../__tests__/bonus-lifecycle-port.test.ts | 86 ++ .../core/src/promo/bonus/contract/index.ts | 19 +- .../migrations/0011_flat_omega_sentinel.sql | 1 + .../migrations/meta/0011_snapshot.json | 1027 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/bonus/plugin.ts | 16 + .../service/bonus-lifecycle-port.service.ts | 49 + .../src/server/runtime/core-token-catalog.ts | 2 + 17 files changed, 1294 insertions(+), 10 deletions(-) create mode 100644 .changeset/bonus-cashback-source-and-unlocked-email.md create mode 100644 packages/core/src/contracts/adapters/bonus-lifecycle.ts create mode 100644 packages/core/src/promo/bonus/__tests__/bonus-lifecycle-port.test.ts create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json create mode 100644 packages/core/src/promo/bonus/service/bonus-lifecycle-port.service.ts diff --git a/.changeset/bonus-cashback-source-and-unlocked-email.md b/.changeset/bonus-cashback-source-and-unlocked-email.md new file mode 100644 index 000000000..a69e59a0f --- /dev/null +++ b/.changeset/bonus-cashback-source-and-unlocked-email.md @@ -0,0 +1,14 @@ +--- +'@openora/core': minor +--- + +Adds `cashback` to `BONUS_GRANT_SOURCES` so a scheduled job (a periodic net-loss cashback, not a +deposit) can call `BONUS_GRANTS.grant` with a system actor without misusing the `deposit` or +`manual` source. Also wires an email (`bonusUnlocked`) alongside the existing in-app +`promo.bonus.completed` notification, matching the `raceWon`/`rankChallengeWon` pattern - a +player is now told by email, not just in-app, when a bonus grant clears its wagering +requirement. `PromoOfferRulesSchema` (jsonb, no migration) gains two optional operator-tunable +fields, `freeSpins` and `periodDays`, for offer mechanics core has no dedicated grant shape for +yet. Adds a new command port, `BONUS_LIFECYCLE.forfeit(grantId, reason, note)` - a single named +grant taken away by a system/job context with no admin session to assert, the shape a scheduled +job needs that `forfeitAllFor` (reached only from inside the bonus module) cannot give it. 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/index.ts b/packages/core/src/contracts/adapters/index.ts index 9afbdf469..05886354a 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, diff --git a/packages/core/src/contracts/schemas/mail.ts b/packages/core/src/contracts/schemas/mail.ts index b4f136315..f9c59292b 100644 --- a/packages/core/src/contracts/schemas/mail.ts +++ b/packages/core/src/contracts/schemas/mail.ts @@ -28,6 +28,7 @@ export const MAIL_TEMPLATE_KEYS = [ 'securityAntiPhishingCodeChanged', 'raceWon', 'rankChallengeWon', + 'bonusUnlocked', ] as const; export type EmailTemplateKey = (typeof MAIL_TEMPLATE_KEYS)[number]; @@ -104,6 +105,10 @@ export const EmailTemplateDataSchemas = { physicalItem: z.string().nullable(), currency: CurrencyTickerSchema, }), + bonusUnlocked: z.object({ + convertedAmount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + }), } as const satisfies Record; export type EmailTemplateData = { @@ -139,6 +144,7 @@ export const MailTemplateSchema = z.discriminatedUnion('key', [ templateVariant('securityAntiPhishingCodeChanged'), templateVariant('raceWon'), templateVariant('rankChallengeWon'), + templateVariant('bonusUnlocked'), ]); export type MailTemplate = { diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index eee53619d..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; /** 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/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 0b704f39d..e2540d396 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -269,13 +269,22 @@ 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', 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 7a733a9c2..9c4a420ca 100644 --- a/packages/core/src/mail/adapters/default-email-template-renderer.ts +++ b/packages/core/src/mail/adapters/default-email-template-renderer.ts @@ -196,6 +196,10 @@ const PLAIN_EMAIL_TEMPLATES: { [K in EmailTemplateKey]: PlainTemplate } = { 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/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/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/0011_flat_omega_sentinel.sql b/packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql new file mode 100644 index 000000000..452bfa3ea --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql @@ -0,0 +1 @@ +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/0011_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json new file mode 100644 index 000000000..bfbaf9682 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json @@ -0,0 +1,1027 @@ +{ + "id": "8c266c4b-5a06-4ae9-a07b-3a7b8736e54a", + "prevId": "a4b0bd27-e144-4f5a-8b5d-abf4f1c87c0e", + "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 8d0adc915..0c7137a34 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1790321212163, "tag": "0010_narrow_ricochet", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1790325601021, + "tag": "0011_flat_omega_sentinel", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index aeefa2054..a7f09947e 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'; @@ -86,6 +89,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; 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/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, From b0a7cd03f7f3d795d266c3d7c1948d4eff037556 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 12:28:48 +0200 Subject: [PATCH 22/28] style(bonus): reformat migration snapshot JSON (BF-0) check:format was failing on pre-existing whitespace drift in two drizzle snapshot files from an earlier commit on this branch. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../migrations/meta/0010_snapshot.json | 87 ++++-------------- .../migrations/meta/0011_snapshot.json | 88 ++++--------------- 2 files changed, 30 insertions(+), 145 deletions(-) diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json index ec0f01512..11b81c98c 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json @@ -437,16 +437,8 @@ "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" - ], + "columnsFrom": ["grant_id", "user_id", "currency"], + "columnsTo": ["id", "user_id", "currency"], "onDelete": "restrict", "onUpdate": "no action" } @@ -593,9 +585,7 @@ "promo_offer_key_unique": { "name": "promo_offer_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -679,12 +669,8 @@ "name": "promo_opt_in_offer_id_promo_offer_id_fk", "tableFrom": "promo_opt_in", "tableTo": "promo_offer", - "columnsFrom": [ - "offer_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["offer_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -760,12 +746,8 @@ "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" - ], + "columnsFrom": ["opt_in_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -870,12 +852,8 @@ "name": "promo_weight_profile_id_promo_weight_profile_id_fk", "tableFrom": "promo_weight", "tableTo": "promo_weight_profile", - "columnsFrom": [ - "profile_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -930,9 +908,7 @@ "promo_weight_profile_name_unique": { "name": "promo_weight_profile_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -957,60 +933,27 @@ "public.promo_grant_entry_type": { "name": "promo_grant_entry_type", "schema": "public", - "values": [ - "grant", - "stake", - "win", - "reversal", - "convert", - "forfeit", - "expire" - ] + "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" - ] + "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain"] }, "public.promo_grant_status": { "name": "promo_grant_status", "schema": "public", - "values": [ - "pending", - "active", - "completed", - "expired", - "forfeited", - "cancelled" - ] + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] }, "public.promo_offer_status": { "name": "promo_offer_status", "schema": "public", - "values": [ - "draft", - "active", - "paused", - "archived" - ] + "values": ["draft", "active", "paused", "archived"] }, "public.promo_weight_scope": { "name": "promo_weight_scope", "schema": "public", - "values": [ - "game", - "category", - "product", - "default" - ] + "values": ["game", "category", "product", "default"] } }, "schemas": {}, diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json index bfbaf9682..d466741b8 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json @@ -437,16 +437,8 @@ "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" - ], + "columnsFrom": ["grant_id", "user_id", "currency"], + "columnsTo": ["id", "user_id", "currency"], "onDelete": "restrict", "onUpdate": "no action" } @@ -593,9 +585,7 @@ "promo_offer_key_unique": { "name": "promo_offer_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -679,12 +669,8 @@ "name": "promo_opt_in_offer_id_promo_offer_id_fk", "tableFrom": "promo_opt_in", "tableTo": "promo_offer", - "columnsFrom": [ - "offer_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["offer_id"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -760,12 +746,8 @@ "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" - ], + "columnsFrom": ["opt_in_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -870,12 +852,8 @@ "name": "promo_weight_profile_id_promo_weight_profile_id_fk", "tableFrom": "promo_weight", "tableTo": "promo_weight_profile", - "columnsFrom": [ - "profile_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -930,9 +908,7 @@ "promo_weight_profile_name_unique": { "name": "promo_weight_profile_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -957,61 +933,27 @@ "public.promo_grant_entry_type": { "name": "promo_grant_entry_type", "schema": "public", - "values": [ - "grant", - "stake", - "win", - "reversal", - "convert", - "forfeit", - "expire" - ] + "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" - ] + "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" - ] + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] }, "public.promo_offer_status": { "name": "promo_offer_status", "schema": "public", - "values": [ - "draft", - "active", - "paused", - "archived" - ] + "values": ["draft", "active", "paused", "archived"] }, "public.promo_weight_scope": { "name": "promo_weight_scope", "schema": "public", - "values": [ - "game", - "category", - "product", - "default" - ] + "values": ["game", "category", "product", "default"] } }, "schemas": {}, From d938ecfcd238bd1a54513d12336e97e2d73bcbbf Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 13:13:40 +0200 Subject: [PATCH 23/28] feat(server): multiplex several push-subscriptions into one SSE stream (BF-400) Adds createMultiplexedEventStreamGenerator to @openora/core/server, folding several independent push-subscriptions (eg a set of per-user realtime channels) into one SSE-servable async generator instead of one connection per channel, tagging each event {channel, payload}. A consumer opening several per-user streams from the same client can now serve them over a single HTTP connection, which matters under HTTP/1.1's per-origin connection cap. Also re-exports each first-party module's per-user channel-name function from its public server entrypoint (walletBalanceChannel, kycStatusChannel, bonusBalanceChannel, notificationsChannel, sessionEventsChannel/SessionEventsPush) - they already existed but were only reachable from each module's internal router file, which a consumer's own realtime route cannot import without a deep-import boundary violation. --- .changeset/multiplexed-event-stream.md | 24 +++++++ packages/core/src/compliance/server.ts | 2 +- packages/core/src/engagement/server.ts | 1 + packages/core/src/pam/server.ts | 1 + packages/core/src/promo/server.ts | 1 + .../kernel/__tests__/event-stream.test.ts | 66 +++++++++++++++++++ .../core/src/server/kernel/event-stream.ts | 33 ++++++++++ packages/core/src/server/kernel/index.ts | 7 +- packages/core/src/wallet/server.ts | 2 +- 9 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 .changeset/multiplexed-event-stream.md create mode 100644 packages/core/src/server/kernel/__tests__/event-stream.test.ts diff --git a/.changeset/multiplexed-event-stream.md b/.changeset/multiplexed-event-stream.md new file mode 100644 index 000000000..008c87cd0 --- /dev/null +++ b/.changeset/multiplexed-event-stream.md @@ -0,0 +1,24 @@ +--- +'@openora/core': minor +--- + +Adds `createMultiplexedEventStreamGenerator` to `@openora/core/server`, folding several +independent push-subscriptions (eg a set of per-user realtime channels) into one SSE-servable +async generator instead of one connection per channel. Each yielded event is tagged +`{ channel, payload }` with the channel name that produced it. A consumer opening several +per-user streams from the same client (balance, notifications, status updates, ...) can now serve +them over a single HTTP connection, which matters under HTTP/1.1's per-origin connection cap: +several permanently-open SSE streams otherwise starve ordinary API requests to the same origin +behind that cap. Every folded channel is subscribed for the generator's lifetime and torn down +together, same as `createEventStreamGenerator`, which this builds on and continues to export +unchanged. + +Also re-exports each first-party module's per-user realtime channel-name function from its +public `server` entrypoint - `walletBalanceChannel` (`./wallet/server`), `kycStatusChannel` +(`./compliance/server`), `bonusBalanceChannel` (`./promo/server`), `notificationsChannel` +(`./engagement/server`), and `sessionEventsChannel` plus its `SessionEventsPush` push shape +(`./pam/server`). These functions already existed; they were only reachable from each module's +internal router file, which a consumer's own realtime route cannot import without a deep-import +boundary violation. Re-exporting them at the public entrypoint is what lets a consumer build a +route on `createMultiplexedEventStreamGenerator` that subscribes a user to several first-party +channels at once, without duplicating the channel-naming convention. 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/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/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/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/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'; From f15dd15a09b4aef6cb79e9d1ec23983482ae3774 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 14:11:09 +0200 Subject: [PATCH 24/28] fix(compliance): price cumulative deposits into the pivot currency for re-KYC handleDeposit summed deposits into player.currency and looked reverifyThresholds up by that same key, so a threshold configured under the platform's exchange-rate pivot (e.g. USD) only ever fired for players whose own wallet currency happened to match it. Price into resolveExchangeRatePivot(platformConfig.exchangeRate) instead, which is also the currency reverifyThresholds is configured against. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../__tests__/kyc.service.int.test.ts | 20 +++++++++++++- .../src/compliance/service/kyc.service.ts | 27 ++++++++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) 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/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; From d85d5386813f44dd3436e2c1b6ed346b928e140a Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 14:11:25 +0200 Subject: [PATCH 25/28] fix(promo): credit cash prizes in a wallet-held currency, gate granted on credit Race prizes, rank-challenge cash tiers and streak cash rewards were credited in their own source currency (often USD) unconditionally, which could open a balance the player's wallet never otherwise holds. Price each cash credit into resolveWalletDefaultCurrency(platformConfig.wallet) before crediting (new priceForPayout helper); a missing rate throws so the settlement transaction rolls back and the payout job retries it on the next tick instead of crediting the wrong currency or losing the prize. The same rollback fixes payout status: a failed or unpriced credit previously still recorded the payout as granted and announced it. Now nothing is persisted or emitted until the credit inside the same transaction actually succeeds, so an absent payout row is the pending state and the job's existing retry loop picks it up again. Every race/rank-challenge/streak cash credit and rakeback credit now emits wallet.balance.changed after its transaction commits, following the pattern gaming.service already uses for bet/win. Rakeback's credit happens inside the bet's own wager-tracking transaction, so its result is threaded back up through WagerTrackingCommands -> BonusWagerOutcome -> WalletDebitOutcome for the caller to emit post-commit. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- .../__tests__/gaming.service.int.test.ts | 43 +++++++++ .../casino/gaming/service/gaming.service.ts | 96 +++++++++++-------- .../src/contracts/adapters/bonus-wagering.ts | 3 + packages/core/src/contracts/adapters/index.ts | 6 +- .../src/contracts/adapters/wager-tracking.ts | 16 +++- .../src/contracts/adapters/wallet-commands.ts | 8 ++ .../schemas/__tests__/platform-config.test.ts | 13 ++- .../src/contracts/schemas/platform-config.ts | 13 +++ .../__tests__/wagering.service.int.test.ts | 4 +- .../promo/bonus/service/wagering.service.ts | 36 +++---- .../__tests__/payout-currency.test.ts | 33 +++++++ .../__tests__/race-payout.int.test.ts | 69 ++++++++++++- .../__tests__/rakeback.int.test.ts | 31 ++++++ .../__tests__/rank-challenge.int.test.ts | 52 +++++++++- .../gamification/__tests__/rank.int.test.ts | 2 +- .../__tests__/streak-payout.int.test.ts | 47 ++++++++- .../core/src/promo/gamification/plugin.ts | 59 +++++++++++- .../service/race-payout.service.ts | 90 +++++++++++------ .../gamification/service/race.service.ts | 6 +- .../gamification/service/rakeback.service.ts | 23 +++-- .../service/rank-challenge-payout.service.ts | 63 ++++++++---- .../service/rank-challenge.service.ts | 18 ++-- .../gamification/service/rank.service.ts | 14 +-- .../service/streak-payout.service.ts | 63 +++++++++--- .../gamification/service/streak.service.ts | 14 +-- .../gamification/shared/payout-currency.ts | 35 +++++++ .../wallet/service/wallet-commands.service.ts | 1 + 27 files changed, 702 insertions(+), 156 deletions(-) create mode 100644 packages/core/src/promo/gamification/__tests__/payout-currency.test.ts create mode 100644 packages/core/src/promo/gamification/shared/payout-currency.ts 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/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 05886354a..2c7c6a03c 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -69,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 800a9b79c..9ea1e09b0 100644 --- a/packages/core/src/contracts/adapters/wager-tracking.ts +++ b/packages/core/src/contracts/adapters/wager-tracking.ts @@ -27,8 +27,22 @@ export type WagerTrackingArgs = { 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 f80dbb9b9..7f61b9258 100644 --- a/packages/core/src/contracts/adapters/wallet-commands.ts +++ b/packages/core/src/contracts/adapters/wallet-commands.ts @@ -6,6 +6,7 @@ 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; @@ -50,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. */ 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/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 854d04170..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 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 index 30292b2a7..340080f0d 100644 --- a/packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts @@ -19,7 +19,7 @@ afterAll(async () => { 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 wagerTracking: WagerTrackingCommands = { recordWager: vi.fn(async () => []) }; const wagering = new WageringService(wagerTracking); const userId = randomUUID(); @@ -44,7 +44,7 @@ describe('wager() with no attributed bonus grant', () => { }); it('does not report a bet requesting bonus funds it has no grant for', async () => { - const wagerTracking: WagerTrackingCommands = { recordWager: vi.fn(async () => {}) }; + const wagerTracking: WagerTrackingCommands = { recordWager: vi.fn(async () => []) }; const wagering = new WageringService(wagerTracking); const outcome = await db.drizzle.db.transaction((tx) => diff --git a/packages/core/src/promo/bonus/service/wagering.service.ts b/packages/core/src/promo/bonus/service/wagering.service.ts index f0c1ec0e4..fa3c9edd9 100644 --- a/packages/core/src/promo/bonus/service/wagering.service.ts +++ b/packages/core/src/promo/bonus/service/wagering.service.ts @@ -78,14 +78,15 @@ export class WageringService implements BonusWageringCommands { // 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. - await this.wagerTracking?.recordWager(tx, { - userId: args.userId, - currency: args.currency, - amount: args.stake, - weightedAmount: args.stake, - realAmount: args.stake, - context: args.context, - }); + 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, @@ -93,6 +94,7 @@ export class WageringService implements BonusWageringCommands { weightedAmount: ZERO, bonusBalanceAfter: ZERO, completed: null, + walletCredits, }; } @@ -167,14 +169,15 @@ export class WageringService implements BonusWageringCommands { } } - 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, - }); + 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, @@ -183,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-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts index 2d1ce2e3a..f4a7b72d4 100644 --- a/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts @@ -3,7 +3,11 @@ 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 { PlayEligibilityPort, WalletCommands } from '@openora/core/contracts'; +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'; @@ -12,6 +16,7 @@ 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[] = [ @@ -19,11 +24,15 @@ const POSITIONS: RacePosition[] = [ { position: 2, prize: '250' }, ]; -const service = () => +// 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, ); @@ -178,3 +187,59 @@ describe('settling a closed race', () => { 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__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts index 44fe9c9fd..2e527346f 100644 --- a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -141,4 +141,35 @@ describe('instant rakeback on a qualifying bet', () => { 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 index bdb683d37..a2ccaf45d 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -80,11 +80,15 @@ const claimsOf = () => }) .from(promoRankChallengeClaim); -const payoutService = () => +// 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, ); @@ -239,6 +243,52 @@ describe('settling a claim', () => { }); }); +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); 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 2472b8247..d6da14e03 100644 --- a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts @@ -177,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( 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 index e3277f8b0..35a98fae2 100644 --- a/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts @@ -5,6 +5,7 @@ import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock } from '../../../testing/mock.js'; import type { BonusGrantCommands, + ExchangeRateReader, PlayEligibilityPort, WalletCommands, } from '@openora/core/contracts'; @@ -17,15 +18,20 @@ 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' }] }]; -const service = () => +// 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 }), ); @@ -121,3 +127,42 @@ describe('settling a cash streak reward', () => { 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/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index 848f5943a..1a1468caf 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -3,6 +3,7 @@ import { AUDIT_WRITER, BONUS_GRANTS, EXCHANGE_RATE_READER, + IDENTITY_READER, JOB_QUEUE, PLATFORM_CONFIG, PLAY_ELIGIBILITY, @@ -11,8 +12,11 @@ import { PromoConfigSchema, WAGER_TRACKING, queue, + resolveWalletDefaultCurrency, + type IdentityReader, type WagerTrackingArgs, type WagerTrackingCommands, + type WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { ADMIN_GUARD, @@ -63,10 +67,12 @@ const EmptyJobSchema = z.object({}); class CompositeWagerTracking implements WagerTrackingCommands { constructor(private readonly consumers: readonly WagerTrackingCommands[]) {} - async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + const credits: WagerTrackingWalletCredit[] = []; for (const consumer of this.consumers) { - await consumer.recordWager(tx, args); + credits.push(...(await consumer.recordWager(tx, args))); } + return credits; } } @@ -107,6 +113,29 @@ export default { 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: RANK_PAYOUT_QUEUE, @@ -136,10 +165,13 @@ export default { logger.warn({}, 'streak payout skipped - service not constructed'); return; } - const granted = await streakPayouts.settlePending(); + 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); + } }, }); @@ -172,6 +204,7 @@ export default { // they do not have yet. for (const win of won) { events?.emit('promo.race.won', win); + await emitCashCredit(win); } }, }); @@ -189,11 +222,24 @@ export default { // 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) => { + // 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, @@ -206,6 +252,8 @@ export default { 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, ); @@ -213,17 +261,22 @@ export default { 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 : {}, ); diff --git a/packages/core/src/promo/gamification/service/race-payout.service.ts b/packages/core/src/promo/gamification/service/race-payout.service.ts index e77b623a2..fb83570e4 100644 --- a/packages/core/src/promo/gamification/service/race-payout.service.ts +++ b/packages/core/src/promo/gamification/service/race-payout.service.ts @@ -1,7 +1,13 @@ import { and, asc, desc, eq, isNull, lte } from 'drizzle-orm'; -import type { PlayEligibilityPort, Uuid, WalletCommands } from '@openora/core/contracts'; +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 = { @@ -9,8 +15,11 @@ export type RaceWon = { 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 = { @@ -36,6 +45,8 @@ export class RacePayoutService { 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, ) {} @@ -100,44 +111,65 @@ export class RacePayoutService { if (!position) { continue; } - const outcome = (await this.eligibility?.isRestricted(standing.userId)) ?? true; - const grantId: string | null = null; - if (!outcome && this.wallet) { - const sourceRef = `race-payout:${raceId}:${standing.userId}`; - const credited = await this.wallet.credit(tx, { + 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, - type: 'cashback', - allowNewCurrency: true, - providerRef: { providerName: 'promo-race', providerRefId: sourceRef }, + grantId: null, + outcome: 'restricted', }); - if (!credited.ok) { - this.logger.error( - { userId: standing.userId, raceId, reason: credited.reason }, - 'race prize credit failed', - ); - } + 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: position.prize, - currency: race.currency, - grantId, - outcome: outcome ? 'restricted' : 'granted', + 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, }); - if (!outcome) { - won.push({ - userId: standing.userId, - raceId, - raceName: race.name, - position: index + 1, - amount: position.prize, - currency: race.currency, - }); - } } await tx diff --git a/packages/core/src/promo/gamification/service/race.service.ts b/packages/core/src/promo/gamification/service/race.service.ts index e2447a38f..a336fe18c 100644 --- a/packages/core/src/promo/gamification/service/race.service.ts +++ b/packages/core/src/promo/gamification/service/race.service.ts @@ -6,6 +6,7 @@ import type { Uuid, WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { makeNotFoundError, @@ -93,9 +94,9 @@ export class RaceService implements WagerTrackingCommands { private readonly logger: { warn: (context: object, message: string) => void }, ) {} - async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { if (moneyCompare(args.realAmount, '0') <= 0) { - return; + return []; } const now = new Date(); const open = await tx @@ -137,6 +138,7 @@ export class RaceService implements WagerTrackingCommands { }, }); } + return []; } async listActive(now: Date): Promise { diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts index b0e2da162..7d7d75161 100644 --- a/packages/core/src/promo/gamification/service/rakeback.service.ts +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'; import type { WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, WalletCommands, } from '@openora/core/contracts'; import { @@ -46,16 +47,16 @@ export class RakebackService implements WagerTrackingCommands { private readonly logger: { warn: (context: object, message: string) => void }, ) {} - async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { const wallet = this.getWallet(); if (!wallet || moneyCompare(args.realAmount, ZERO) <= 0) { - return; + return []; } const [config] = await tx .select({ eligibleProducts: promoRankConfig.eligibleProducts }) .from(promoRankConfig); if (!config || !countsToward(config.eligibleProducts, args.context.product)) { - return; + return []; } const [rank] = await tx .select({ @@ -66,14 +67,14 @@ export class RakebackService implements WagerTrackingCommands { .from(promoPlayerRank) .where(eq(promoPlayerRank.userId, args.userId)); if (!rank?.tierId) { - return; + return []; } const [tier] = await tx .select({ rakebackPercent: promoRankTier.rakebackPercent }) .from(promoRankTier) .where(eq(promoRankTier.id, rank.tierId)); if (!tier) { - return; + return []; } const boostActive = rank.rakebackBoostExpiresAt !== null && rank.rakebackBoostExpiresAt > new Date(); @@ -83,8 +84,12 @@ export class RakebackService implements WagerTrackingCommands { ); const rakeback = moneyDivide(moneyScaleBy(args.realAmount, rate), '100'); if (moneyCompare(rakeback, ZERO) <= 0) { - return; + 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, @@ -93,6 +98,12 @@ export class RakebackService implements WagerTrackingCommands { }); 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-payout.service.ts b/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts index dbc51a89b..2da8dabc0 100644 --- a/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts +++ b/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts @@ -1,12 +1,14 @@ 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 = { @@ -14,9 +16,12 @@ export type RankChallengeWon = { 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 = { @@ -40,6 +45,8 @@ export class RankChallengePayoutService { 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, ) {} @@ -89,33 +96,56 @@ export class RankChallengePayoutService { .where(eq(promoRankChallengeTier.id, claim.tierId)); const restricted = (await this.eligibility?.isRestricted(claim.userId)) ?? true; - const cashGrantId: string | null = null; - if (claim.cashAmount !== null && !restricted && this.wallet) { + 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: claim.cashAmount, - currency: claim.currency, + amount: priced.amount, + currency: priced.currency, type: 'cashback', allowNewCurrency: true, providerRef: { providerName: 'promo-rank-challenge', providerRefId: sourceRef }, }); if (!credited.ok) { - this.logger.error( - { userId: claim.userId, tierId: claim.tierId, reason: credited.reason }, - 'rank challenge cash credit failed', - ); + // 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}`); } - } else if (claim.cashAmount !== null && restricted) { - this.logger.warn( - { userId: claim.userId, tierId: claim.tierId }, - 'rank challenge cash withheld - player restricted', - ); + 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 }) + .set({ + settledAt: new Date(), + outcome: restricted ? 'restricted' : 'granted', + cashGrantId: null, + }) .where(eq(promoRankChallengeClaim.id, claimId)); await this.audit.recordInTransaction(tx, { @@ -141,9 +171,10 @@ export class RankChallengePayoutService { tierId: claim.tierId, tierKey: tier?.key ?? '', tierName: tier?.name ?? '', - cashAmount: claim.cashAmount, + cashAmount: paidCashAmount, physicalItem: claim.physicalItem, - currency: claim.currency, + 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 index 756cc1cd2..c1086de42 100644 --- a/packages/core/src/promo/gamification/service/rank-challenge.service.ts +++ b/packages/core/src/promo/gamification/service/rank-challenge.service.ts @@ -5,6 +5,7 @@ import type { Uuid, WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { moneyCompare, type DrizzleService, type DrizzleTx } from '@openora/core/server'; import type { @@ -54,9 +55,9 @@ export class RankChallengeService implements WagerTrackingCommands { private readonly logger: { warn: (context: object, message: string) => void }, ) {} - async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { if (moneyCompare(args.realAmount, '0') <= 0) { - return; + return []; } const ladder = await tx .select(TIER_COLUMNS) @@ -64,7 +65,7 @@ export class RankChallengeService implements WagerTrackingCommands { .orderBy(asc(promoRankChallengeTier.position)); const [lowest] = ladder; if (!lowest) { - return; + return []; } const currency = await tx .select({ currency: promoRankChallengeTier.currency }) @@ -72,7 +73,7 @@ export class RankChallengeService implements WagerTrackingCommands { .where(eq(promoRankChallengeTier.id, lowest.id)); const ladderCurrency = currency[0]?.currency; if (!ladderCurrency) { - return; + return []; } const amount = args.currency === ladderCurrency @@ -85,7 +86,7 @@ export class RankChallengeService implements WagerTrackingCommands { { userId: args.userId, from: args.currency, to: ladderCurrency }, 'rank challenge wager skipped - no exchange rate', ); - return; + return []; } const [wager] = await tx @@ -100,14 +101,14 @@ export class RankChallengeService implements WagerTrackingCommands { }) .returning({ lifetimeWagered: promoRankChallengeWager.lifetimeWagered }); if (!wager) { - return; + return []; } const crossed = ladder.filter( (tier) => moneyCompare(tier.wagerThreshold, wager.lifetimeWagered) <= 0, ); if (crossed.length === 0) { - return; + return []; } const alreadyClaimed = await tx .select({ tierId: promoRankChallengeClaim.tierId }) @@ -116,7 +117,7 @@ export class RankChallengeService implements WagerTrackingCommands { const claimedIds = new Set(alreadyClaimed.map((c) => c.tierId)); const contestable = crossed.filter((t) => !claimedIds.has(t.id)); if (contestable.length === 0) { - return; + return []; } for (const tier of contestable.sort((a, b) => a.position - b.position)) { @@ -141,6 +142,7 @@ export class RankChallengeService implements WagerTrackingCommands { // 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. */ diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index 8d910db6a..c7828bb35 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -4,6 +4,7 @@ import type { ExchangeRateReader, WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { makeNotFoundError, @@ -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. */ diff --git a/packages/core/src/promo/gamification/service/streak-payout.service.ts b/packages/core/src/promo/gamification/service/streak-payout.service.ts index f004d188a..9f28d3532 100644 --- a/packages/core/src/promo/gamification/service/streak-payout.service.ts +++ b/packages/core/src/promo/gamification/service/streak-payout.service.ts @@ -3,6 +3,7 @@ import { and, asc, eq, isNull } from 'drizzle-orm'; import type { BonusGrantCommands, DomainEventPayload, + ExchangeRateReader, PlayEligibilityPort, Uuid, WalletCommands, @@ -10,9 +11,22 @@ import type { 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; @@ -44,20 +58,22 @@ export class StreakPayoutService { 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 { + async settlePending(): Promise { if (!this.grants || !this.eligibility) { this.logger.warn({}, 'streak payout skipped - bonus grants or play eligibility not bound'); - return []; + return { granted: [], cashPaid: [] }; } const [config] = await this.drizzle.db .select({ milestones: promoStreakConfig.milestones }) .from(promoStreakConfig); if (!config) { - return []; + return { granted: [], cashPaid: [] }; } const owed = await this.drizzle.db .select({ id: promoStreakMilestoneGrant.id }) @@ -67,33 +83,35 @@ export class StreakPayoutService { .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.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; + return { granted, cashPaid }; } private async settleOne( tx: DrizzleTx, id: Uuid, milestones: readonly { day: number; rewards: readonly StreakReward[] }[], - ): Promise { + ): 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 []; + return { granted: [], cashPaid: [] }; } const settle = (outcome: string) => tx @@ -103,10 +121,11 @@ export class StreakPayoutService { if ((await this.eligibility?.isRestricted(row.userId)) ?? true) { await settle('restricted'); - return []; + 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') { @@ -114,7 +133,10 @@ export class StreakPayoutService { continue; } if (reward.kind === 'cash') { - await this.grantCash(tx, row.userId, reward, sourceRef); + 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); @@ -123,7 +145,7 @@ export class StreakPayoutService { } } await settle('granted'); - return granted; + return { granted, cashPaid }; } private async grantOne( @@ -183,22 +205,35 @@ export class StreakPayoutService { 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: reward.amount, - currency, + amount: priced.amount, + currency: priced.currency, type: 'cashback', allowNewCurrency: true, providerRef: { providerName: 'promo-streak', providerRefId: sourceRef }, }); if (!outcome.ok) { - this.logger.error({ userId, sourceRef, reason: outcome.reason }, 'streak cash reward failed'); + // 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() { diff --git a/packages/core/src/promo/gamification/service/streak.service.ts b/packages/core/src/promo/gamification/service/streak.service.ts index 413d8db58..6221d9809 100644 --- a/packages/core/src/promo/gamification/service/streak.service.ts +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -5,6 +5,7 @@ import type { Uuid, WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { makeNotFoundError, @@ -52,9 +53,9 @@ export class StreakService implements WagerTrackingCommands { private readonly logger: Logger, ) {} - 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({ @@ -66,7 +67,7 @@ export class StreakService implements WagerTrackingCommands { }) .from(promoStreakConfig); if (!config || !countsToward(config.eligibleProducts, args.context.product)) { - return; + return []; } const amount = args.currency === config.currency @@ -79,7 +80,7 @@ export class StreakService implements WagerTrackingCommands { { userId: args.userId, from: args.currency, to: config.currency, amount: args.amount }, 'streak wager skipped - no exchange rate', ); - return; + return []; } const today = isoDate(new Date()); @@ -95,7 +96,7 @@ export class StreakService implements WagerTrackingCommands { }) .returning({ wagered: promoStreakDailyWager.wagered }); if (!day || moneyCompare(day.wagered, config.dailyMinWager) < 0) { - return; + return []; } // One row per player, upserted per bet - `where` skips the update entirely once today has @@ -115,7 +116,7 @@ export class StreakService implements WagerTrackingCommands { }) .returning({ current: promoPlayerStreak.current }); if (!advanced) { - return; + return []; } if (config.milestones.some((milestone) => milestone.day === advanced.current)) { @@ -138,6 +139,7 @@ export class StreakService implements WagerTrackingCommands { .set({ current: 0, updatedAt: sql`now()` }) .where(eq(promoPlayerStreak.userId, args.userId)); } + return []; } async getForPlayer(userId: Uuid): Promise { 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/wallet/service/wallet-commands.service.ts b/packages/core/src/wallet/service/wallet-commands.service.ts index 16b062af0..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, }; } From f46dc6745126c576cb360bcf8a121c771471535e Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 14:36:09 +0200 Subject: [PATCH 26/28] fix(promo): count real-money stake for streaks, forfeit bonuses on ban, push notifications live The daily streak qualified on a wager's full stake instead of realAmount, so a bonus-funded bet advanced it - streak.service.ts now matches RaceService/RankChallengeService and counts only the player's own money. A ban (identity.user.deactivated) did not forfeit a player's active bonus grants, unlike self-exclusion, cooling-off and account closure. The bonus plugin now wires the same forfeitEverything path to it, with an 'admin' reason. NotificationsService.create() emits notifications.created regardless of which instance calls it, but only the notifications module's own dispatch jobs ever published the result onto the realtime channel - a caller with its own NotificationsService instance (an overlay reacting to an event outside domainEventSchemas) got no live push. The plugin now subscribes to notifications.created itself and re-reads + publishes the row, so every create() call gets the same realtime push. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- ...orfeit-streak-realmoney-notify-realtime.md | 16 ++++++++ .../src/contracts/adapters/wager-tracking.ts | 7 ++-- .../notifications.service.int.test.ts | 21 ++++++++++ .../src/engagement/notifications/plugin.ts | 23 ++++++++++- .../service/notifications.service.ts | 15 +++++++ packages/core/src/promo/bonus/plugin.ts | 41 ++++++++++++++----- .../gamification/__tests__/streak.int.test.ts | 25 ++++++++++- .../gamification/service/streak.service.ts | 12 ++++-- 8 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 .changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md diff --git a/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md b/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md new file mode 100644 index 000000000..e3999918f --- /dev/null +++ b/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md @@ -0,0 +1,16 @@ +--- +'@openora/core': patch +--- + +Three fixes: + +- The daily streak now qualifies on `WagerTrackingArgs.realAmount` (the own-money part of a + stake) instead of the full stake, matching `RaceService`/`RankChallengeService` - a + bonus-funded bet no longer advances the streak. +- A ban (`identity.user.deactivated`) now forfeits a player's active bonus grants the same way + a self-exclusion, a cooling-off period or an account closure already do. +- `NotificationsService` gains `getById`, and the notifications module now pushes any created + notification onto its realtime channel from a `notifications.created` subscription rather + than only from its own dispatch jobs - so a consumer that calls `create()` on its own + `NotificationsService` instance for an event outside `domainEventSchemas` (both share the + same event bus) gets the same live push core's own notifications already did. diff --git a/packages/core/src/contracts/adapters/wager-tracking.ts b/packages/core/src/contracts/adapters/wager-tracking.ts index 9ea1e09b0..2f6b25598 100644 --- a/packages/core/src/contracts/adapters/wager-tracking.ts +++ b/packages/core/src/contracts/adapters/wager-tracking.ts @@ -19,9 +19,10 @@ export type WagerTrackingArgs = { weightedAmount: string; /** * The part of `amount` staked out of the player's own funds - `amount` minus whatever a bonus - * grant covered. Rank/streak counters intentionally ignore this and count the full stake (see - * their own doc comments); it exists for a consumer that must not reward money the player never - * risked, such as real-money rakeback. + * 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; 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/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index e2540d396..e855c3b42 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -463,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) { @@ -641,7 +662,6 @@ export default { if (!record) { return; } - publishNotification(record); if (payload.eventId) { await dispatchMail( payload.userId, @@ -672,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/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index a7f09947e..1eb69df07 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -52,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({}); /** @@ -194,17 +204,18 @@ export default { }); // A bonus is money a player may not keep once they have excluded themselves, entered a - // cooling-off period or closed the account, and the rule is immediate rather than "by the - // next sweep". + // 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', + | 'player.account.closed' + | 'identity.user.deactivated', >( topic: K, - reason: 'self_exclusion' | 'cooling_off' | 'account_closed', + reason: 'self_exclusion' | 'cooling_off' | 'account_closed' | 'admin', ) => (payload: unknown) => { const parsed = domainEventSchemas[topic].safeParse(payload); @@ -217,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 @@ -259,6 +272,12 @@ export default { '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/gamification/__tests__/streak.int.test.ts b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts index 046ba6810..e3a0c03f8 100644 --- a/packages/core/src/promo/gamification/__tests__/streak.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts @@ -66,14 +66,19 @@ beforeEach(async () => { await db.drizzle.db.insert(promoStreakConfig).values(CONFIG); }); -const record = (userId: string, amount: string, context: WagerContext = CASINO) => +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: amount, + realAmount, context, }), ); @@ -101,6 +106,22 @@ describe('recordWager', () => { 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++) { diff --git a/packages/core/src/promo/gamification/service/streak.service.ts b/packages/core/src/promo/gamification/service/streak.service.ts index 6221d9809..b0e5827b3 100644 --- a/packages/core/src/promo/gamification/service/streak.service.ts +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -45,6 +45,10 @@ type Logger = { warn: (context: object, message: string) => void }; * * 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( @@ -54,7 +58,7 @@ export class StreakService implements WagerTrackingCommands { ) {} async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { - if (moneyCompare(args.amount, '0') <= 0) { + if (moneyCompare(args.realAmount, '0') <= 0) { return []; } const [config] = await tx @@ -71,13 +75,13 @@ export class StreakService implements WagerTrackingCommands { } const amount = args.currency === config.currency - ? args.amount - : await this.rates.convert(args.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.amount }, + { userId: args.userId, from: args.currency, to: config.currency, amount: args.realAmount }, 'streak wager skipped - no exchange rate', ); return []; From e8591868ded1ddb6795b7dc1753fe4ccdb17096a Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 15:31:24 +0200 Subject: [PATCH 27/28] chore: drop changesets from this branch --- ...orfeit-streak-realmoney-notify-realtime.md | 16 ------------- ...onus-cashback-source-and-unlocked-email.md | 14 ----------- .changeset/bonus-forfeit-on-cooling-off.md | 5 ---- .changeset/bonus-system-forfeit.md | 5 ---- .changeset/multiplexed-event-stream.md | 24 ------------------- .changeset/notification-p2p-transfer-types.md | 5 ---- .changeset/rank-challenge-mechanic.md | 22 ----------------- .changeset/rank-rakeback-payout-engine.md | 22 ----------------- .changeset/rates-batch-unknown-code.md | 7 ------ .changeset/rg-limit-ordering.md | 9 ------- .../streak-engine-and-wager-tracking-fix.md | 13 ---------- .changeset/wager-challenge-races.md | 11 --------- .changeset/wallet-credit-grant-terms.md | 5 ---- 13 files changed, 158 deletions(-) delete mode 100644 .changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md delete mode 100644 .changeset/bonus-cashback-source-and-unlocked-email.md delete mode 100644 .changeset/bonus-forfeit-on-cooling-off.md delete mode 100644 .changeset/bonus-system-forfeit.md delete mode 100644 .changeset/multiplexed-event-stream.md delete mode 100644 .changeset/notification-p2p-transfer-types.md delete mode 100644 .changeset/rank-challenge-mechanic.md delete mode 100644 .changeset/rank-rakeback-payout-engine.md delete mode 100644 .changeset/rates-batch-unknown-code.md delete mode 100644 .changeset/rg-limit-ordering.md delete mode 100644 .changeset/streak-engine-and-wager-tracking-fix.md delete mode 100644 .changeset/wager-challenge-races.md delete mode 100644 .changeset/wallet-credit-grant-terms.md diff --git a/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md b/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md deleted file mode 100644 index e3999918f..000000000 --- a/.changeset/bonus-ban-forfeit-streak-realmoney-notify-realtime.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@openora/core': patch ---- - -Three fixes: - -- The daily streak now qualifies on `WagerTrackingArgs.realAmount` (the own-money part of a - stake) instead of the full stake, matching `RaceService`/`RankChallengeService` - a - bonus-funded bet no longer advances the streak. -- A ban (`identity.user.deactivated`) now forfeits a player's active bonus grants the same way - a self-exclusion, a cooling-off period or an account closure already do. -- `NotificationsService` gains `getById`, and the notifications module now pushes any created - notification onto its realtime channel from a `notifications.created` subscription rather - than only from its own dispatch jobs - so a consumer that calls `create()` on its own - `NotificationsService` instance for an event outside `domainEventSchemas` (both share the - same event bus) gets the same live push core's own notifications already did. diff --git a/.changeset/bonus-cashback-source-and-unlocked-email.md b/.changeset/bonus-cashback-source-and-unlocked-email.md deleted file mode 100644 index a69e59a0f..000000000 --- a/.changeset/bonus-cashback-source-and-unlocked-email.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@openora/core': minor ---- - -Adds `cashback` to `BONUS_GRANT_SOURCES` so a scheduled job (a periodic net-loss cashback, not a -deposit) can call `BONUS_GRANTS.grant` with a system actor without misusing the `deposit` or -`manual` source. Also wires an email (`bonusUnlocked`) alongside the existing in-app -`promo.bonus.completed` notification, matching the `raceWon`/`rankChallengeWon` pattern - a -player is now told by email, not just in-app, when a bonus grant clears its wagering -requirement. `PromoOfferRulesSchema` (jsonb, no migration) gains two optional operator-tunable -fields, `freeSpins` and `periodDays`, for offer mechanics core has no dedicated grant shape for -yet. Adds a new command port, `BONUS_LIFECYCLE.forfeit(grantId, reason, note)` - a single named -grant taken away by a system/job context with no admin session to assert, the shape a scheduled -job needs that `forfeitAllFor` (reached only from inside the bonus module) cannot give it. diff --git a/.changeset/bonus-forfeit-on-cooling-off.md b/.changeset/bonus-forfeit-on-cooling-off.md deleted file mode 100644 index 3231e0efc..000000000 --- a/.changeset/bonus-forfeit-on-cooling-off.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@openora/core': minor ---- - -A player entering a cooling-off period now forfeits every active bonus immediately, the same as a self-exclusion or account closure. `BONUS_FORFEIT_REASONS` gains `cooling_off` (new enum value on `promo_forfeit_reason`), and the bonus plugin now also subscribes to `rg.cooling_off.activated`. diff --git a/.changeset/bonus-system-forfeit.md b/.changeset/bonus-system-forfeit.md deleted file mode 100644 index d7100e5e6..000000000 --- a/.changeset/bonus-system-forfeit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@openora/core': minor ---- - -`GrantLifecycleService.forfeit()` now takes an optional actor instead of a required one, so a scheduled job can forfeit a single named grant (an Activity Bonus that missed a required wagering day, for example) with no admin session to assert - the same system-actor path `forfeitAllFor` already supported. `BONUS_FORFEIT_REASONS` gains `terms_breach` (new enum value on `promo_forfeit_reason`) for offer-terms breaches a job detects rather than an admin or an RG event. diff --git a/.changeset/multiplexed-event-stream.md b/.changeset/multiplexed-event-stream.md deleted file mode 100644 index 008c87cd0..000000000 --- a/.changeset/multiplexed-event-stream.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@openora/core': minor ---- - -Adds `createMultiplexedEventStreamGenerator` to `@openora/core/server`, folding several -independent push-subscriptions (eg a set of per-user realtime channels) into one SSE-servable -async generator instead of one connection per channel. Each yielded event is tagged -`{ channel, payload }` with the channel name that produced it. A consumer opening several -per-user streams from the same client (balance, notifications, status updates, ...) can now serve -them over a single HTTP connection, which matters under HTTP/1.1's per-origin connection cap: -several permanently-open SSE streams otherwise starve ordinary API requests to the same origin -behind that cap. Every folded channel is subscribed for the generator's lifetime and torn down -together, same as `createEventStreamGenerator`, which this builds on and continues to export -unchanged. - -Also re-exports each first-party module's per-user realtime channel-name function from its -public `server` entrypoint - `walletBalanceChannel` (`./wallet/server`), `kycStatusChannel` -(`./compliance/server`), `bonusBalanceChannel` (`./promo/server`), `notificationsChannel` -(`./engagement/server`), and `sessionEventsChannel` plus its `SessionEventsPush` push shape -(`./pam/server`). These functions already existed; they were only reachable from each module's -internal router file, which a consumer's own realtime route cannot import without a deep-import -boundary violation. Re-exporting them at the public entrypoint is what lets a consumer build a -route on `createMultiplexedEventStreamGenerator` that subscribes a user to several first-party -channels at once, without duplicating the channel-naming convention. diff --git a/.changeset/notification-p2p-transfer-types.md b/.changeset/notification-p2p-transfer-types.md deleted file mode 100644 index c5e664b42..000000000 --- a/.changeset/notification-p2p-transfer-types.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@openora/core': minor ---- - -Adds three notification types for player-to-player transfers: `chat.tip.received`, `chat.gift.claimed`, `chat.gift.expired`, alongside the existing `chat.rain.received`. Additive to the closed `NotificationTypeSchema` enum; no consumer of the existing types is affected. diff --git a/.changeset/rank-challenge-mechanic.md b/.changeset/rank-challenge-mechanic.md deleted file mode 100644 index a2d9b6d0c..000000000 --- a/.changeset/rank-challenge-mechanic.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@openora/core': minor ---- - -Adds a Rank Challenge mechanic to the gamification module: a ladder of tiers, each with a -lifetime real-money wagering threshold, where the first player to cross a tier's threshold wins -its prize, once. A tier's prize may be a cash amount, a physical item, or both; cash is credited -automatically once a claim settles, physical prizes go to a new operator-facing fulfilment queue -(list pending, mark fulfilled with a note, audited). - -New player-facing reads: `rankChallenge.get` (progress toward the next unclaimed tier, a top-5 -leaderboard, the player's own position) and `rankChallenge.ladder` (public tier list with winner -info). New admin routes under `admin.rankChallenge`: replace the ladder as one set (prospective -only - a claim snapshots its own prize at the moment it is won, so editing a tier afterward never -changes what a past winner was granted), list every claim, and run the fulfilment queue. - -Concurrency: two players crossing the same tier at once resolve to exactly one winner via a -unique index on the claim's tier, checked through `onConflictDoNothing` plus a `.returning()` -check rather than a pre-check select, so there is no TOCTOU gap. - -New domain event `promo.rank-challenge.won` and notification type (in-app + email), mirroring -`promo.race.won`. New `seedRankChallengeLadder` seed helper, mirroring `seedRankLadder`. diff --git a/.changeset/rank-rakeback-payout-engine.md b/.changeset/rank-rakeback-payout-engine.md deleted file mode 100644 index d3f6d9f13..000000000 --- a/.changeset/rank-rakeback-payout-engine.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@openora/core': minor ---- - -Rank rakeback now actually pays: `WAGER_TRACKING` gains a `RakebackService` consumer that credits -a qualifying bet's own-money stake, at the player's rank rakeback percentage (tier rate plus any -active streak boost), straight to their real balance in the same transaction as the bet. No house -edge factor and no claim step - rakeback lands on the balance as it accrues. Only the part of a -stake the player's own funds paid for counts: `WAGER_TRACKING`'s `WagerTrackingArgs` gains a -`realAmount` field alongside `amount`, so a bonus-funded stake never earns real-money rakeback on -funds the player never risked. - -A new wallet transaction type, `cashback`, covers this and any other operator-funded real-money -credit that carries no wagering requirement - the daily-streak reward kind `cash` (alongside the -existing `bonus`, `giftDrop` and `rakebackBoost`) uses the same type for a milestone paid straight -to the balance rather than through a bonus grant. - -`gamificationContract.ranks` gains a public `lookup` endpoint - a batched, public-fields-only rank -badge lookup for a set of user ids (tier key and name, nothing wagered or earned), for a chat -avatar or profile card to show another player's real rank instead of a placeholder. - -Needs a migration: `ALTER TYPE wallet_transaction_type ADD VALUE 'cashback'`. diff --git a/.changeset/rates-batch-unknown-code.md b/.changeset/rates-batch-unknown-code.md deleted file mode 100644 index ab35a32a5..000000000 --- a/.changeset/rates-batch-unknown-code.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@openora/core': patch ---- - -`GET /exchange-rate/rates` answers a source currency the operator does not offer with a null quote instead of rejecting the whole batch. - -`GET /profile/display-currency` ignores a saved pick the operator no longer offers and falls back to the player's most valuable balance. diff --git a/.changeset/rg-limit-ordering.md b/.changeset/rg-limit-ordering.md deleted file mode 100644 index 61c3a9370..000000000 --- a/.changeset/rg-limit-ordering.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@openora/core': minor ---- - -Responsible-gambling deposit/wager/loss limits now enforce `daily <= weekly <= monthly` server-side, compared in one currency through `EXCHANGE_RATE_READER` when the sibling periods differ in currency. This applies to a player's own limit changes (`RgSelfServiceService.upsertLimit`, evaluated against the effective value - including a parked raise's pending amount, since that is what applies once confirmed) and to the admin reduce-only override (`RgService.setPlayerLimit`, since a decrease can still cross a sibling bound). A limit removal is never subject to this check - dropping a bound only loosens the ordering, never violates it. - -A missing exchange rate refuses the whole write rather than skipping the check. Both paths throw the new `LimitOrderingViolationError` (mapped to `CONFLICT`/409), carrying `type`, `period`, `conflictingPeriod` and `bound` (the sibling's effective amount and currency) so a client can render e.g. "Weekly limit can't be lower than your daily limit (29 USD)" without parsing the message. - -**Behaviour change:** a limit set that was previously accepted but left `daily`/`weekly`/`monthly` out of order for the same type is now refused. No migration - existing out-of-order rows are left as-is and only block the next attempted change on that type. diff --git a/.changeset/streak-engine-and-wager-tracking-fix.md b/.changeset/streak-engine-and-wager-tracking-fix.md deleted file mode 100644 index f8e21dd51..000000000 --- a/.changeset/streak-engine-and-wager-tracking-fix.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@openora/core': minor ---- - -Fixed `wager()` in the bonus wagering engine: a real-money bet placed with no active bonus grant -never reached `WAGER_TRACKING`, so the rank ladder (and anything else on that port) only advanced -for a player mid-bonus. It now reports the bet at its full stake regardless. - -Added a daily streak engine alongside the rank ladder in `promo/gamification`: a config-driven -daily qualifying wager, a milestone list (bonus, gift-drop, and rank-rakeback-boost rewards), a -UTC close job, an idempotent milestone-payout job, and a top-5 leaderboard. Bound onto the same -sealed `WAGER_TRACKING` port as the rank ladder through a small internal fan-out, so a bet is -still reported to both from one call site. diff --git a/.changeset/wager-challenge-races.md b/.changeset/wager-challenge-races.md deleted file mode 100644 index 0af0763ee..000000000 --- a/.changeset/wager-challenge-races.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@openora/core': minor ---- - -Wager challenges (leaderboard races) in `promo/gamification`: an operator-configured window of wagering volume, ranked, with a prize pool split across paid positions. - -A race is priced in one currency, with `startAt`/`endAt`, a `prizePool`, and `positions` (`{ position, prize }[]`, prizes summing to at most the pool). `WAGER_TRACKING` gains a fourth consumer alongside the rank ladder, rakeback and streak: every real-money bet in an eligible product (the race's own product list, same convention as the rank ladder and streak) accrues into the player's standing for every race currently open, converted into the race's currency at the rate of the moment. Bonus-funded stake is never counted. - -`GET /promo/races` lists open races; `GET /promo/races/{raceId}` returns the race's own config, a top-3 podium, a capped leaderboard (up to 100 ranked entries total - real pagination is left for a race that outgrows that), the caller's own standing (their true position and wagered total, never affected by their own privacy setting), and how much more they need to wager to reach the next paid position. Every other player's username on the leaderboard is partially masked, or shown as `Incognito` for a player who set the new `hideUsernameOnLeaderboards` profile preference (`PATCH /profile`) - their own row is never masked to themselves, and the setting never touches their standing or prize eligibility. - -`POST /backoffice/promo/races` and `PUT /backoffice/promo/races/{raceId}` create and edit a race, audited like the rank ladder; editing is refused once a race has closed. A short recurring job closes a race once its window ends, freezes final standings (ties broken by whoever reached the total first), pays every position through a direct real-cash credit (idempotent - a retried tick never pays twice), and emits `promo.race.won` per winner, which now sends both an in-app notification and an email. diff --git a/.changeset/wallet-credit-grant-terms.md b/.changeset/wallet-credit-grant-terms.md deleted file mode 100644 index 17daa9d76..000000000 --- a/.changeset/wallet-credit-grant-terms.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@openora/core': minor ---- - -`WALLET_COMMANDS.credit` now accepts optional bonus grant `terms` (wagering multiplier, expiry days, and the rest of `BonusGrantTerms`) for `gift`/`rain` credits, forwarded to `BONUS_GRANTS.grant`. Omitting it keeps today's behaviour: the bonus module's own default terms. From 2dcb1ef0a12efa8d9eb30a095c7f547a880d3cdd Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 15:37:23 +0200 Subject: [PATCH 28/28] chore(promo): squash the branch's bonus and gamification migrations into one each --- .../migrations/0009_amazing_forgotten_one.sql | 1 - ...009_bonus_offer_rules_cashback_forfeit.sql | 3 + .../migrations/0010_narrow_ricochet.sql | 1 - .../migrations/0011_flat_omega_sentinel.sql | 1 - .../migrations/meta/0009_snapshot.json | 7 +- .../migrations/meta/0010_snapshot.json | 969 ----------- .../migrations/meta/0011_snapshot.json | 969 ----------- .../drizzle/migrations/meta/_journal.json | 18 +- .../migrations/0012_bitter_sister_grimm.sql | 51 - ..._rakeback_streaks_races_rank_challenge.sql | 147 ++ .../drizzle/migrations/0013_broad_reavers.sql | 49 - .../migrations/0014_rank_challenge.sql | 47 - .../migrations/meta/0012_snapshot.json | 654 ++++++- .../migrations/meta/0013_snapshot.json | 1245 ------------- .../migrations/meta/0014_snapshot.json | 1535 ----------------- .../drizzle/migrations/meta/_journal.json | 18 +- 16 files changed, 811 insertions(+), 4904 deletions(-) delete mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0009_bonus_offer_rules_cashback_forfeit.sql delete mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql delete mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql delete mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json delete mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json delete mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0012_gamification_rakeback_streaks_races_rank_challenge.sql delete mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql delete mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql delete mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json delete mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql b/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql deleted file mode 100644 index 0f9980d56..000000000 --- a/packages/core/src/promo/bonus/drizzle/migrations/0009_amazing_forgotten_one.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'cooling_off' BEFORE 'account_closed'; \ No newline at end of file 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/0010_narrow_ricochet.sql b/packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql deleted file mode 100644 index 05ce915f5..000000000 --- a/packages/core/src/promo/bonus/drizzle/migrations/0010_narrow_ricochet.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'terms_breach'; \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql b/packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql deleted file mode 100644 index 452bfa3ea..000000000 --- a/packages/core/src/promo/bonus/drizzle/migrations/0011_flat_omega_sentinel.sql +++ /dev/null @@ -1 +0,0 @@ -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 index 4f9e6ae95..494ea1da8 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json @@ -1,5 +1,5 @@ { - "id": "6dfe7e38-f784-416c-8162-161bbf9460e3", + "id": "10e6f7a1-a889-4ce1-9367-250771b78ce6", "prevId": "113e30a1-ffac-401a-ab0c-f194efc58d82", "version": "7", "dialect": "postgresql", @@ -926,7 +926,8 @@ "account_closed", "admin", "player_opt_out", - "withdrawal_while_active" + "withdrawal_while_active", + "terms_breach" ] }, "public.promo_grant_entry_type": { @@ -937,7 +938,7 @@ "public.promo_grant_source": { "name": "promo_grant_source", "schema": "public", - "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain"] + "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain", "cashback"] }, "public.promo_grant_status": { "name": "promo_grant_status", diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json deleted file mode 100644 index 11b81c98c..000000000 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0010_snapshot.json +++ /dev/null @@ -1,969 +0,0 @@ -{ - "id": "a4b0bd27-e144-4f5a-8b5d-abf4f1c87c0e", - "prevId": "6dfe7e38-f784-416c-8162-161bbf9460e3", - "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"] - }, - "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/0011_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json deleted file mode 100644 index d466741b8..000000000 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0011_snapshot.json +++ /dev/null @@ -1,969 +0,0 @@ -{ - "id": "8c266c4b-5a06-4ae9-a07b-3a7b8736e54a", - "prevId": "a4b0bd27-e144-4f5a-8b5d-abf4f1c87c0e", - "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 0c7137a34..0af20e63a 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -68,22 +68,8 @@ { "idx": 9, "version": "7", - "when": 1790321212162, - "tag": "0009_amazing_forgotten_one", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1790321212163, - "tag": "0010_narrow_ricochet", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1790325601021, - "tag": "0011_flat_omega_sentinel", + "when": 1790343346597, + "tag": "0009_bonus_offer_rules_cashback_forfeit", "breakpoints": true } ] diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql b/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql deleted file mode 100644 index 0a2995056..000000000 --- a/packages/core/src/promo/gamification/drizzle/migrations/0012_bitter_sister_grimm.sql +++ /dev/null @@ -1,51 +0,0 @@ -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_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 -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/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/0013_broad_reavers.sql b/packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql deleted file mode 100644 index e31d9a7d7..000000000 --- a/packages/core/src/promo/gamification/drizzle/migrations/0013_broad_reavers.sql +++ /dev/null @@ -1,49 +0,0 @@ -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 -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"); \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql b/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql deleted file mode 100644 index 804619b9f..000000000 --- a/packages/core/src/promo/gamification/drizzle/migrations/0014_rank_challenge.sql +++ /dev/null @@ -1,47 +0,0 @@ -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 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; \ 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 index 2fb516a45..415482af3 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json @@ -1,5 +1,5 @@ { - "id": "9fb1c1a4-1336-414e-b47b-895f9f24eb7a", + "id": "62e3ef7b-3e83-47d2-a4a5-5d896204e20b", "prevId": "005b663c-47a9-46e8-acaf-68895ec889c6", "version": "7", "dialect": "postgresql", @@ -173,6 +173,658 @@ }, "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": "", diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json deleted file mode 100644 index e7105adcf..000000000 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/0013_snapshot.json +++ /dev/null @@ -1,1245 +0,0 @@ -{ - "id": "a6da3aa8-11d2-4708-b3fa-3c1a4e6b1523", - "prevId": "9fb1c1a4-1336-414e-b47b-895f9f24eb7a", - "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_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/0014_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json deleted file mode 100644 index aecc5f7fc..000000000 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/0014_snapshot.json +++ /dev/null @@ -1,1535 +0,0 @@ -{ - "id": "27f37f05-5156-442a-bd97-5bdba022f997", - "prevId": "a6da3aa8-11d2-4708-b3fa-3c1a4e6b1523", - "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 03aca2a86..6895b67ed 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -89,22 +89,8 @@ { "idx": 12, "version": "7", - "when": 1790306558604, - "tag": "0012_bitter_sister_grimm", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1790313747654, - "tag": "0013_broad_reavers", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1790316717848, - "tag": "0014_rank_challenge", + "when": 1790343347170, + "tag": "0012_gamification_rakeback_streaks_races_rank_challenge", "breakpoints": true } ]