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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/pivot-priced-compliance-totals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@openora/core': minor
---

Re-KYC's cumulative-deposit trigger, the `high_roller` tag's lifetime-deposit total, and the withdrawal queue's `large_amount` tag are now priced in a single reference currency before they are compared or summed, instead of adding or comparing raw amounts across whatever currencies a player happens to hold.

A platform with no base currency lets a player deposit and withdraw in several coins at once. `KycVerificationService.handleDeposit` used to sum only the deposits already in the player's own currency field - on a crypto-only platform with no matching wallet currency, that read $0 forever and re-KYC never fired. `WalletReader.getLifetimeDeposit` (the `high_roller` rule's input) and the withdrawal queue's `large_amount` heuristic both summed or compared raw `wallet_transaction.amount` regardless of currency - `1 BTC + 20000 DOGE` read as `20001`.

All three now convert through the already-existing `EXCHANGE_RATE_READER` port: re-KYC sums every currency into the player's own currency field, `getLifetimeDeposit` sums into `exchangeRate.pivot` (`USD` by default, and now always the configured pivot rather than a hardcoded default), and the queue tag prices the withdrawal into the same pivot before comparing it to `LARGE_WITHDRAWAL_THRESHOLD` (previously a raw, currency-blind compare - the queue tag's own doc comment used to call this "a display hint, not a decision"; it is a decision now).

**Missing-rate behaviour, and a breaking port change:** a compliance total must never silently undercount because one currency had no quote, and it must never fabricate a value either - a made-up total sums, compares and gets written down exactly like a real one, and nothing downstream can tell the difference. So:

- `WalletReader.getLifetimeDeposit(userId): Promise<string | null>` - the return type changed from `string` to `string | null`. `null` means at least one currency could not be priced. Every core caller handles it explicitly and in the direction that never quietly loses the compliance signal: `high_roller` skips the evaluation and logs a warning (never assigns or removes off a guess - the tag is sticky); `basic_kyc_needed` (only asks "any deposit on file") still assigns, since `null` can only happen when a deposit exists; bonus eligibility's `isFirstDeposit` fallback treats `null` as "not the first deposit". A custom `WalletReader` implementation (or a mock) built against the old `Promise<string>` signature needs updating.
- The withdrawal queue's per-transaction `large_amount` check has no running total to lose, so an unpriced withdrawal is flagged rather than skipped - never a silent pass.
- `KycVerificationService.handleDeposit` never writes a `kyc_verification` row from an unpriced total: on an unpriced currency it logs an error (`logger.error` + `reportError`, naming the userId and the unpriced currencies) and returns without evaluating the threshold or moving the re-KYC watermark. The next deposit re-evaluates once rates are available again.

`WalletReaderService`'s constructor now takes a single deps object (`{ drizzle, defaultCurrency?, exchangeRateReader?, pivotCurrency }`) instead of positional params - `pivotCurrency` is required and should be wired from `resolveExchangeRatePivot(platformConfig.exchangeRate)`, not hardcoded. `KycVerificationDeps` gains an optional `exchangeRateReader`. Both are already wired this way in their plugins.
80 changes: 79 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 @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites
import { randomUUID } from 'node:crypto';
import { desc, eq, sql } from 'drizzle-orm';
import type {
ExchangeRateReader,
KycAdapter,
KycStatusWriter,
KycVendorStatus,
Expand All @@ -21,7 +22,13 @@ let db: TestDb;

type AdapterResult = { referenceId: string; status: KycVendorStatus; verificationUrl?: string };

function makeService(options: { adapter?: Partial<AdapterResult>; config?: PlatformConfig } = {}) {
function makeService(
options: {
adapter?: Partial<AdapterResult>;
config?: PlatformConfig;
exchangeRateReader?: ExchangeRateReader;
} = {},
) {
const events = makeEventBus();
const audit = makeAuditWriter();
const adapterResult: AdapterResult = {
Expand All @@ -42,6 +49,7 @@ function makeService(options: { adapter?: Partial<AdapterResult>; config?: Platf
statusWriter,
identityReader: makeIdentityReader(),
...(options.config ? { platformConfig: options.config } : {}),
...(options.exchangeRateReader ? { exchangeRateReader: options.exchangeRateReader } : {}),
});
return { svc, events, audit, kycAdapter, statusWriter, adapterResult };
}
Expand Down Expand Up @@ -88,6 +96,24 @@ async function verificationsOf(userId: string) {
.orderBy(desc(kycVerification.createdAt));
}

// A fake pivot-rate reader for the fx-aware handleDeposit tests: `prices` gives the USD value
// of one whole unit of a currency, `unpriced` names currencies that answer null (no quote).
function makeRates(prices: Record<string, string>, unpriced: string[] = []): ExchangeRateReader {
return mock<ExchangeRateReader>({
getRate: vi.fn(async () => null),
convert: vi.fn(async (amount: string, from: string, to: string) => {
if (from === to) {
return amount;
}
if (unpriced.includes(from)) {
return null;
}
const price = prices[from];
return price === undefined ? null : String(Number(amount) * Number(price));
}),
});
}

beforeAll(async () => {
db = await createTestDb([migrate, migrateProfile, migrateWallet]);
});
Expand Down Expand Up @@ -551,6 +577,58 @@ describe('KycVerificationService.handleDeposit - threshold re-KYC (real PG)', ()
);
expect(await verificationsOf(userId)).toHaveLength(2);
});

it('fires on the deposit that crosses the threshold once summed across currencies', async () => {
const rates = makeRates({ BTC: '6000', ETH: '5000' });
const { svc, statusWriter } = makeService({
config: mock<PlatformConfig>({ kyc: { reverifyThresholds: { USD: '10000' } } }),
exchangeRateReader: rates,
});
const { userId } = await seedPlayer();

await seedDeposit(userId, '1', 'BTC');
await svc.handleDeposit(userId);
expect(statusWriter.setStatus).not.toHaveBeenCalled();

await seedDeposit(userId, '1', 'ETH');
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(11000);
});

it('writes no row when a deposit currency cannot be priced, then fires once rates return', async () => {
const config = mock<PlatformConfig>({ kyc: { reverifyThresholds: { USD: '10000' } } });
const { userId } = await seedPlayer();
await seedDeposit(userId, '1', 'DOGE');

const unpriced = makeService({ config, exchangeRateReader: makeRates({}, ['DOGE']) });
await unpriced.svc.handleDeposit(userId);

expect(unpriced.statusWriter.setStatus).not.toHaveBeenCalled();
expect(await verificationsOf(userId)).toHaveLength(0);

await seedDeposit(userId, '1', 'BTC');
const priced = makeService({
config,
exchangeRateReader: makeRates({ DOGE: '1', BTC: '20000' }),
});
await priced.svc.handleDeposit(userId);

expect(priced.statusWriter.setStatus).toHaveBeenCalledWith(
userId,
'resubmission_requested',
expect.objectContaining({ source: 'reverify' }),
expect.anything(),
);
expect(await verificationsOf(userId)).toHaveLength(1);
});
});

describe('KycVerificationService.applyExemption (real PG)', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/compliance/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export default {
statusWriter: c.get(KYC_STATUS_WRITER),
identityReader: c.get(IDENTITY_READER),
platformConfig,
exchangeRateReader: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined,
});
kycRef = kyc;
const compliance = makeComplianceService(c);
Expand Down
57 changes: 53 additions & 4 deletions packages/core/src/compliance/service/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
findOneOrThrow,
makeNotFoundError,
mapConcurrent,
reportError,
serializeRow,
sumInPivot,
withAdvisoryXactLock,
type EventBus,
} from '@openora/core/server';
import {
normalizeKycStatus,
type KycAdapter,
type AuditWritePort,
type ExchangeRateReader,
type KycCheckResult,
type KycDocument,
type KycRiskSignals,
Expand Down Expand Up @@ -179,6 +182,7 @@ export type KycVerificationDeps = {
identityReader: IdentityReader;
platformConfig?: PlatformConfig;
reKycTrigger?: ReKycTrigger;
exchangeRateReader?: ExchangeRateReader;
};

export class KycVerificationService {
Expand All @@ -190,6 +194,7 @@ export class KycVerificationService {
private readonly identityReader: IdentityReader;
private readonly platformConfig?: PlatformConfig;
private readonly reKycTrigger: ReKycTrigger;
private readonly exchangeRateReader?: ExchangeRateReader;

constructor(deps: KycVerificationDeps) {
this.drizzle = deps.drizzle;
Expand All @@ -200,6 +205,7 @@ export class KycVerificationService {
this.identityReader = deps.identityReader;
this.platformConfig = deps.platformConfig;
this.reKycTrigger = deps.reKycTrigger ?? new CumulativeDepositReKycTrigger();
this.exchangeRateReader = deps.exchangeRateReader;
}

private get provider() {
Expand Down Expand Up @@ -601,11 +607,32 @@ export class KycVerificationService {
};
}

// Names which of `rows`' currencies sumInPivot could not price, for the error log/report
// when a re-KYC evaluation has to be skipped - sumInPivot itself only says "some row failed".
private async unpricedCurrencies(
rows: readonly { currency: string; total: string }[],
pivotCurrency: string,
): Promise<string[]> {
const others = rows.filter((row) => row.currency.toUpperCase() !== pivotCurrency.toUpperCase());
const checked = await mapConcurrent(others, 5, async (row) => ({
currency: row.currency,
priced: this.exchangeRateReader
? (await this.exchangeRateReader.convert(row.total, row.currency, pivotCurrency)) !== null
: false,
}));
return checked.filter((row) => !row.priced).map((row) => row.currency);
}

/**
* Deposit-event hook: flips a currently-approved player to `resubmission_requested`
* once cumulative deposits cross a fresh per-currency threshold band. Idempotent twice
* 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.
*/
async handleDeposit(userId: User['id']) {
const [current] = await this.drizzle.db
Expand All @@ -616,18 +643,41 @@ export class KycVerificationService {
return;
}

const [deposited] = await this.drizzle.db
.select({ total: sql<string>`coalesce(sum(${walletTransaction.amount}), 0)` })
const depositsByCurrency = await this.drizzle.db
.select({
currency: walletTransaction.currency,
total: sql<string>`coalesce(sum(${walletTransaction.amount}), 0)`,
})
.from(walletTransaction)
.innerJoin(wallet, eq(wallet.id, walletTransaction.walletId))
.where(
and(
eq(wallet.userId, userId),
eq(walletTransaction.type, 'deposit'),
eq(walletTransaction.status, 'completed'),
eq(walletTransaction.currency, current.currency),
),
)
.groupBy(walletTransaction.currency);
const totalDeposits = await sumInPivot(
depositsByCurrency,
current.currency,
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 err = new Error(
`handleDeposit: no rate for ${unpriced.join(', ')} into ${current.currency}`,
);
logger.error(
{ err, userId, unpriced, pivotCurrency: current.currency },
'handleDeposit: could not price a deposit currency, skipping re-KYC evaluation',
);
reportError(err, { userId, extra: { unpriced, pivotCurrency: current.currency } });
return;
}

const [lastFire] = await this.drizzle.db
.select({ triggerDeposits: kycVerification.triggerDeposits })
Expand All @@ -642,7 +692,6 @@ export class KycVerificationService {
.orderBy(desc(kycVerification.createdAt))
.limit(1);

const totalDeposits = deposited?.total ?? '0';
const snapshot = {
totalDeposits,
currency: current.currency,
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/contracts/adapters/wallet-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ export type WalletBalancesReading = {
};

export type WalletReader = {
/** Sum of all completed deposits for a player, as a decimal string (same as wallet_transaction.amount). Used for high_roller evaluation. */
getLifetimeDeposit(userId: string): Promise<string>;
/**
* Sum of all completed deposits for a player, priced into the implementation's reference
* currency (a decimal string, not necessarily the same unit as wallet_transaction.amount -
* a player can deposit in several currencies). Null when at least one currency's amount
* could not be priced; never a partial or fabricated total. Used for high_roller evaluation.
*/
getLifetimeDeposit(userId: string): Promise<string | null>;
/** Always answers: a player with no wallet row yet gets an empty `balances` array and the platform's default wallet currency, never a throw. */
getBalances(userId: string): Promise<WalletBalancesReading>;
/** Count of completed withdrawals for a player within the last windowDays days. Used for high_risk evaluation. */
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/pam/tag/service/tag-evaluation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type TagRule,
type User,
} from '@openora/core/contracts';
import { moneyToNumber, mapConcurrent, type DrizzleTx } from '@openora/core/server';
import { moneyToNumber, mapConcurrent, createLogger, type DrizzleTx } from '@openora/core/server';
import { TagService, TagAlreadyInUseError, TagAssignmentNotFoundError } from './tag.service.js';
import { TagRuleService, TagRuleNotFoundError } from './tag-rule.service.js';
import {
Expand All @@ -23,6 +23,7 @@ import {
const EVAL_CHUNK_SIZE = 100;
const MULTI_ACCOUNT_REASON = 'identity signal matched another player account';
const BONUS_ABUSER_REASON = 'multi-account risk rule matched';
const logger = createLogger('pam-tag-evaluation');

function isPendingKycStatus(status: KycStatus): boolean {
return status === 'pending' || status === 'resubmission_requested';
Expand Down Expand Up @@ -151,7 +152,14 @@ export class TagEvaluationService {

if (highRoller && highRoller.threshold !== null) {
const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId);
if (moneyToNumber(lifetimeDeposit) >= moneyToNumber(highRoller.threshold)) {
if (lifetimeDeposit === null) {
// Never tag (or untag - a sticky high_roller stays sticky) off a missing rate: a
// fabricated total can't be told apart from a real one once it's compared and assigned.
logger.warn(
{ userId },
'onDepositCompleted: could not price lifetime deposits, skipping high_roller evaluation',
);
} else if (moneyToNumber(lifetimeDeposit) >= moneyToNumber(highRoller.threshold)) {
await this.tryAssignTag({
userId,
tagKey: 'high_roller',
Expand Down Expand Up @@ -420,7 +428,9 @@ export class TagEvaluationService {
return;
}
const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId);
if (moneyToNumber(lifetimeDeposit) > 0) {
// null still means there was at least one deposit (just one this pricing pass couldn't
// total) - this rule only asks "any deposit on file", so an unpriced amount still assigns.
if (lifetimeDeposit === null || moneyToNumber(lifetimeDeposit) > 0) {
await this.tryAssignTag({
userId,
tagKey: 'basic_kyc_needed',
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/promo/bonus/service/offer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,16 @@ export class OfferService {
return context;
}
const lifetime = await this.wallet.getLifetimeDeposit(userId);
return { ...context, isFirstDeposit: moneyCompare(lifetime, '0') === 0 };
// null means at least one currency couldn't be priced, which only happens once a deposit
// exists - so unpriced reads the same as "not the first deposit".
return { ...context, isFirstDeposit: lifetime !== null && moneyCompare(lifetime, '0') === 0 };
}

// Fallback for a WalletReader implementation without isFirstDeposit; null (an unpriced
// currency) reads as "not the first deposit", same direction as offerFacts above.
private async isFirstDepositByLifetimeTotal(userId: Uuid, depositAmount: string) {
const lifetime = await this.wallet.getLifetimeDeposit(userId);
return lifetime !== null && moneyCompare(lifetime, depositAmount) === 0;
}

/** The offers open to this player right now, with what their deposits have put toward each. */
Expand Down Expand Up @@ -242,7 +251,7 @@ export class OfferService {
// `created_at` against every other completed deposit, which does not move once written.
const isFirstDeposit = this.wallet.isFirstDeposit
? await this.wallet.isFirstDeposit(deposit.userId, deposit.transactionId)
: moneyCompare(await this.wallet.getLifetimeDeposit(deposit.userId), deposit.amount) === 0;
: await this.isFirstDepositByLifetimeTotal(deposit.userId, deposit.amount);
// Re-evaluated here rather than trusted from opt-in time: this job can run after a
// self-exclusion or ban that landed between the opt-in and this deposit settling, and the
// forfeit sweep that reacted to that exclusion has no way to know a grant would appear later.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
moneyFloorToScale,
moneyCeilToScale,
mapConcurrent,
sumInPivot,
} from './query-helpers.js';

// Transactional outbox - writer binds atomically with the state change; relay publishes to MESSAGE_BROKER. See ADR-0016.
Expand Down
Loading
Loading