Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5770627
fix(core): answer an unoffered currency with a null quote instead of …
zaxovaiko Sep 24, 2026
2f36a3f
feat(wallet): accept optional bonus grant terms on a credit
zaxovaiko Sep 25, 2026
2c4efad
feat(notifications): add player-to-player transfer notification types
zaxovaiko Sep 25, 2026
7e8fe5c
feat(notifications): expose NotificationsService as a public subpath
zaxovaiko Sep 25, 2026
167166f
feat(compliance): enforce daily<=weekly<=monthly ordering on RG limits
zaxovaiko Sep 25, 2026
b02f33b
fix(promo): count a real-money wager toward WAGER_TRACKING with no bo…
zaxovaiko Sep 25, 2026
6b1e630
feat(wallet): add cashback transaction type and own-money stake track…
zaxovaiko Sep 25, 2026
aa27868
feat(gamification): pay rank rakeback and add a cash streak reward (B…
zaxovaiko Sep 25, 2026
d4e98e8
test(gamification): cover rakeback accrual, cash streak reward, rank …
zaxovaiko Sep 25, 2026
f6252b3
chore(wallet): format the cashback migration snapshot (BF-568)
zaxovaiko Sep 25, 2026
22a58c9
test(wallet): classify cashback as a credit in the direction-backfill…
zaxovaiko Sep 25, 2026
cb45349
test(testing): add realAmount to e2e fixtures after the WagerTracking…
zaxovaiko Sep 25, 2026
dbdc638
fix(gamification): break a startup circular dependency on WALLET_COMM…
zaxovaiko Sep 25, 2026
7ff7379
feat(gamification): add wager challenge (leaderboard race) engine
zaxovaiko Sep 25, 2026
4fdd876
feat(gamification): add Rank Challenge - a race-to-threshold ladder w…
zaxovaiko Sep 25, 2026
12e19be
fix(gamification): key rank challenge fulfilment by tierId, not an un…
zaxovaiko Sep 25, 2026
f5a3de2
chore(gamification): format the rank challenge migration snapshot
zaxovaiko Sep 25, 2026
e3ced79
fix(gamification): kebab-case the rank challenge win event topic
zaxovaiko Sep 25, 2026
d16056f
fix(bonus): forfeit active bonuses on cooling-off, not just self-excl…
zaxovaiko Sep 25, 2026
05fb1ae
fix(bonus): allow a system actor to forfeit a single named grant (BF-…
zaxovaiko Sep 25, 2026
fb3b95a
fix(bonus): add BONUS_LIFECYCLE, cashback source, bonusUnlocked email…
zaxovaiko Sep 25, 2026
b0a7cd0
style(bonus): reformat migration snapshot JSON (BF-0)
zaxovaiko Sep 25, 2026
d938ecf
feat(server): multiplex several push-subscriptions into one SSE strea…
zaxovaiko Sep 25, 2026
f15dd15
fix(compliance): price cumulative deposits into the pivot currency fo…
zaxovaiko Sep 25, 2026
d85d538
fix(promo): credit cash prizes in a wallet-held currency, gate grante…
zaxovaiko Sep 25, 2026
f46dc67
fix(promo): count real-money stake for streaks, forfeit bonuses on ba…
zaxovaiko Sep 25, 2026
e859186
chore: drop changesets from this branch
zaxovaiko Sep 25, 2026
2dcb1ef
chore(promo): squash the branch's bonus and gamification migrations i…
zaxovaiko Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameAdapter>({
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)', () => {
Expand Down
96 changes: 56 additions & 40 deletions packages/core/src/casino/gaming/service/gaming.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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', {
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/compliance/__tests__/kyc.service.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading
Loading