diff --git a/.changeset/display-decimal-places.md b/.changeset/display-decimal-places.md new file mode 100644 index 000000000..59af264c8 --- /dev/null +++ b/.changeset/display-decimal-places.md @@ -0,0 +1,9 @@ +--- +'@openora/core': minor +--- + +Persist a player's display decimal places. `player.display_decimal_places` (nullable, 0-18) is +returned as `decimalPlaces` on `GET/PUT /profile/display-currency` and set through the new +`PUT /profile/display-decimal-places` (audited as `player.display_decimal_places.set`); `null` +clears the pick. React: `useSetDisplayDecimalPlaces` from `@openora/core/pam/react`. Presentation +only - the value never rounds a stored or submitted amount. diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 2fb1bbf97..a716c18c2 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -24,6 +24,7 @@ export type DirectAuditAction = | 'chat.mute.expired' | 'chat.platform_ban.expired' | 'player.display_currency.set' + | 'player.display_decimal_places.set' | 'compliance.kyc.bulk_approve' | 'compliance.country_rule.created' | 'compliance.country_rule.setting_changed' diff --git a/packages/core/src/contracts/schemas/display-currency.ts b/packages/core/src/contracts/schemas/display-currency.ts index d71db2c27..a9ae9d065 100644 --- a/packages/core/src/contracts/schemas/display-currency.ts +++ b/packages/core/src/contracts/schemas/display-currency.ts @@ -42,6 +42,19 @@ export const DisplayCurrencyInputSchema = CurrencyTickerInputSchema; export type DisplayCurrency = z.infer; +// The platform stores every amount at 18 decimal places, so a display precision past that +// would only pad zeros that carry no value. +export const MAX_DISPLAY_DECIMAL_PLACES = 18; + +// How many decimals a player wants amounts rendered with. Presentation only: it never rounds +// a stored or submitted amount. `null` means no pick, so the client uses the currency default. +export const DisplayDecimalPlacesSchema = z + .number() + .int() + .min(0) + .max(MAX_DISPLAY_DECIMAL_PLACES) + .nullable(); + /** * Resolves the operator's supported display-currency list: `platformConfig` * override when present and non-empty, else the built-in default. Uppercases and diff --git a/packages/core/src/pam/profile/__tests__/profile.service.int.test.ts b/packages/core/src/pam/profile/__tests__/profile.service.int.test.ts index e4017778a..4b5dccfb2 100644 --- a/packages/core/src/pam/profile/__tests__/profile.service.int.test.ts +++ b/packages/core/src/pam/profile/__tests__/profile.service.int.test.ts @@ -220,7 +220,7 @@ describe('ProfileService.getMyDisplayCurrency (real PG)', () => { const result = await svc.getMyDisplayCurrency(account.id); - expect(result).toEqual({ currency: 'EUR', supported: DEFAULT_SUPPORTED }); + expect(result).toEqual({ currency: 'EUR', supported: DEFAULT_SUPPORTED, decimalPlaces: null }); }); it('falls back to the currency held with the most value when nothing was chosen', async () => { @@ -299,7 +299,7 @@ describe('ProfileService.setMyDisplayCurrency (real PG)', () => { const result = await svc.setMyDisplayCurrency(account.id, { currency: 'EUR' }); - expect(result).toEqual({ currency: 'EUR', supported: DEFAULT_SUPPORTED }); + expect(result).toEqual({ currency: 'EUR', supported: DEFAULT_SUPPORTED, decimalPlaces: null }); const [row] = await playersFor(account.id); expect(row?.displayCurrency).toBe('EUR'); expect(audit.recordInTransaction).toHaveBeenCalledWith( @@ -325,6 +325,80 @@ describe('ProfileService.setMyDisplayCurrency (real PG)', () => { }); }); +describe('ProfileService.setMyDisplayDecimalPlaces (real PG)', () => { + it('persists the pick, audits the value it replaced, and returns it on the next read', async () => { + const account = await seedUser(db); + const seeded = await seedPlayer(account.id, { + displayCurrency: 'EUR', + displayDecimalPlaces: 2, + }); + const audit = makeAuditWriter(); + const svc = new ProfileService( + db.drizzle, + mock({}), + mock({}), + audit, + DEFAULT_SUPPORTED, + ); + + const result = await svc.setMyDisplayDecimalPlaces(account.id, { decimalPlaces: 6 }); + + expect(result).toEqual({ currency: 'EUR', supported: DEFAULT_SUPPORTED, decimalPlaces: 6 }); + expect(await svc.getMyDisplayCurrency(account.id)).toMatchObject({ decimalPlaces: 6 }); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + actorId: account.id, + actorType: 'player', + action: 'player.display_decimal_places.set', + resourceId: seeded.id, + before: { displayDecimalPlaces: 2 }, + after: { displayDecimalPlaces: 6 }, + }), + ); + }); + + it('clears the pick back to the currency default when set to null', async () => { + const account = await seedUser(db); + await seedPlayer(account.id, { displayCurrency: 'EUR', displayDecimalPlaces: 4 }); + const svc = makeService(); + + const result = await svc.setMyDisplayDecimalPlaces(account.id, { decimalPlaces: null }); + + expect(result.decimalPlaces).toBeNull(); + const [row] = await playersFor(account.id); + expect(row?.displayDecimalPlaces).toBeNull(); + }); + + it('materializes the profile when the pick is the first call for a user', async () => { + const account = await seedUser(db); + const svc = makeService(); + + await svc.setMyDisplayDecimalPlaces(account.id, { decimalPlaces: 8 }); + + const rows = await playersFor(account.id); + expect(rows).toHaveLength(1); + expect(rows[0]?.displayDecimalPlaces).toBe(8); + }); + + it('keeps the stored pick when the display currency changes', async () => { + const account = await seedUser(db); + await seedPlayer(account.id, { displayCurrency: 'USD', displayDecimalPlaces: 4 }); + const svc = makeService(); + + const result = await svc.setMyDisplayCurrency(account.id, { currency: 'EUR' }); + + expect(result.decimalPlaces).toBe(4); + }); + + it('refuses an out-of-range value at the database even when validation is bypassed', async () => { + const account = await seedUser(db); + + await expect(seedPlayer(account.id, { displayDecimalPlaces: 19 })).rejects.toThrow(); + await expect(seedPlayer(account.id, { displayDecimalPlaces: -1 })).rejects.toThrow(); + }); +}); + describe('ProfileService.recordTimezone (real PG)', () => { it('stores the browser-reported zone and stamps when it was captured', async () => { const svc = makeService(); diff --git a/packages/core/src/pam/profile/contract/index.ts b/packages/core/src/pam/profile/contract/index.ts index b33a96981..8e226ea7b 100644 --- a/packages/core/src/pam/profile/contract/index.ts +++ b/packages/core/src/pam/profile/contract/index.ts @@ -5,6 +5,7 @@ import { UpdatePlayerProfileInputSchema, DisplayCurrencyCodeSchema, DisplayCurrencyInputSchema, + DisplayDecimalPlacesSchema, } from '@openora/core/contracts'; // Player-facing self-profile contract. Caller resolved from the verified @@ -18,6 +19,7 @@ export { export const DisplayCurrencyInfoSchema = z.object({ currency: DisplayCurrencyCodeSchema, supported: z.array(DisplayCurrencyCodeSchema), + decimalPlaces: DisplayDecimalPlacesSchema, }); export type DisplayCurrencyInfo = z.infer; @@ -26,6 +28,11 @@ export const SetDisplayCurrencyInputSchema = z.object({ }); export type SetDisplayCurrencyInput = z.infer; +export const SetDisplayDecimalPlacesInputSchema = z.object({ + decimalPlaces: DisplayDecimalPlacesSchema, +}); +export type SetDisplayDecimalPlacesInput = z.infer; + export const profileContract = { get: oc.route({ method: 'GET', path: '/profile' }).output(PlayerSchema), @@ -42,4 +49,9 @@ export const profileContract = { .route({ method: 'PUT', path: '/profile/display-currency' }) .input(SetDisplayCurrencyInputSchema) .output(DisplayCurrencyInfoSchema), + + setDisplayDecimalPlaces: oc + .route({ method: 'PUT', path: '/profile/display-decimal-places' }) + .input(SetDisplayDecimalPlacesInputSchema) + .output(DisplayCurrencyInfoSchema), }; diff --git a/packages/core/src/pam/profile/drizzle/migrations/0006_early_master_mold.sql b/packages/core/src/pam/profile/drizzle/migrations/0006_early_master_mold.sql new file mode 100644 index 000000000..b877df905 --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/0006_early_master_mold.sql @@ -0,0 +1,2 @@ +ALTER TABLE "player" ADD COLUMN "display_decimal_places" integer;--> statement-breakpoint +ALTER TABLE "player" ADD CONSTRAINT "player_display_decimal_places_range" CHECK ("player"."display_decimal_places" BETWEEN 0 AND 18); \ 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..493e89f52 --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,253 @@ +{ + "id": "b42dd8ec-7cab-4ebc-a574-7737325e70af", + "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 + }, + "display_decimal_places": { + "name": "display_decimal_places", + "type": "integer", + "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 + }, + "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": { + "player_display_decimal_places_range": { + "name": "player_display_decimal_places_range", + "value": "\"player\".\"display_decimal_places\" BETWEEN 0 AND 18" + } + }, + "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..bdac01edd 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": 1790334814541, + "tag": "0006_early_master_mold", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/profile/react/profile.ts b/packages/core/src/pam/profile/react/profile.ts index 582161cdd..e0a3fa7ab 100644 --- a/packages/core/src/pam/profile/react/profile.ts +++ b/packages/core/src/pam/profile/react/profile.ts @@ -36,3 +36,12 @@ export function useSetDisplayCurrency() { onSuccess: () => queryClient.invalidateQueries({ queryKey: utils.getDisplayCurrency.key() }), }); } + +export function useSetDisplayDecimalPlaces() { + const utils = useOrpcQueryUtils(profileContract); + const queryClient = useQueryClient(); + return useMutation({ + ...utils.setDisplayDecimalPlaces.mutationOptions(), + onSuccess: () => queryClient.invalidateQueries({ queryKey: utils.getDisplayCurrency.key() }), + }); +} diff --git a/packages/core/src/pam/profile/router/index.ts b/packages/core/src/pam/profile/router/index.ts index 4016a9f49..efcee1457 100644 --- a/packages/core/src/pam/profile/router/index.ts +++ b/packages/core/src/pam/profile/router/index.ts @@ -28,5 +28,9 @@ export function createProfileRouter(profile: ProfileService) { profile.setMyDisplayCurrency(getUserId(context), input), ), ), + + setDisplayDecimalPlaces: os.setDisplayDecimalPlaces.handler(({ input, context }) => + profile.setMyDisplayDecimalPlaces(getUserId(context), input), + ), }); } diff --git a/packages/core/src/pam/profile/schema/index.ts b/packages/core/src/pam/profile/schema/index.ts index aaf335e1d..ae4eaa204 100644 --- a/packages/core/src/pam/profile/schema/index.ts +++ b/packages/core/src/pam/profile/schema/index.ts @@ -8,8 +8,10 @@ import { timestamp, pgEnum, index, + check, } from 'drizzle-orm/pg-core'; -import { PLAYER_STATUSES, KYC_STATUSES } from '@openora/core/contracts'; +import { sql } from 'drizzle-orm'; +import { PLAYER_STATUSES, KYC_STATUSES, MAX_DISPLAY_DECIMAL_PLACES } from '@openora/core/contracts'; export const playerStatusEnum = pgEnum('player_status', PLAYER_STATUSES); export const kycStatusEnum = pgEnum('kyc_status', KYC_STATUSES); @@ -31,6 +33,7 @@ export const player = pgTable( country: text(), currency: text().notNull().default('USD'), displayCurrency: text(), + displayDecimalPlaces: integer(), status: playerStatusEnum().notNull().default('active'), kycStatus: kycStatusEnum().notNull().default('pending'), level: integer().notNull().default(1), @@ -52,7 +55,14 @@ export const player = pgTable( .notNull() .$onUpdateFn(() => new Date()), }, - (t) => [index('player_status_idx').on(t.status), index('player_created_at_idx').on(t.createdAt)], + (t) => [ + index('player_status_idx').on(t.status), + index('player_created_at_idx').on(t.createdAt), + check( + 'player_display_decimal_places_range', + sql`${t.displayDecimalPlaces} BETWEEN 0 AND ${sql.raw(String(MAX_DISPLAY_DECIMAL_PLACES))}`, + ), + ], ); export type Player = typeof player.$inferSelect; diff --git a/packages/core/src/pam/profile/service/profile.service.ts b/packages/core/src/pam/profile/service/profile.service.ts index d9c18fa32..ae09b49a8 100644 --- a/packages/core/src/pam/profile/service/profile.service.ts +++ b/packages/core/src/pam/profile/service/profile.service.ts @@ -19,6 +19,7 @@ import { player } from '../schema/index.js'; import type { UpdatePlayerProfileInput, SetDisplayCurrencyInput, + SetDisplayDecimalPlacesInput, DisplayCurrencyInfo, } from '../contract/index.js'; import { toPlayer, fetchIdentityByUserId } from '../../shared/player-mapper.js'; @@ -177,6 +178,7 @@ export class ProfileService implements PlayerProvisioning { return { currency: await this.resolveEffectiveDisplayCurrency(userId, row), supported: [...this.supportedDisplayCurrencies], + decimalPlaces: row.displayDecimalPlaces, }; } @@ -210,7 +212,54 @@ export class ProfileService implements PlayerProvisioning { }); }); - return { currency: input.currency, supported: [...this.supportedDisplayCurrencies] }; + return { + currency: input.currency, + supported: [...this.supportedDisplayCurrencies], + decimalPlaces: row.displayDecimalPlaces, + }; + } + + async setMyDisplayDecimalPlaces( + userId: User['id'], + input: SetDisplayDecimalPlacesInput, + ): Promise { + await this.ensureProfileRow(userId); + + // The row is locked before `before` is read, so two racing picks each audit the value + // they actually replaced; the write and its record commit together or not at all. + const row = await this.drizzle.db.transaction(async (tx) => { + const [locked] = await tx + .select() + .from(player) + .where(eq(player.userId, userId)) + .limit(1) + .for('update'); + if (!locked) { + throw new ProfileUserNotFoundError(userId); + } + + await tx + .update(player) + .set({ displayDecimalPlaces: input.decimalPlaces }) + .where(eq(player.id, locked.id)); + + await this.audit.recordInTransaction(tx, { + actorId: userId, + actorType: 'player', + action: 'player.display_decimal_places.set', + resourceType: 'player', + resourceId: locked.id, + before: { displayDecimalPlaces: locked.displayDecimalPlaces }, + after: { displayDecimalPlaces: input.decimalPlaces }, + }); + return { ...locked, displayDecimalPlaces: input.decimalPlaces }; + }); + + return { + currency: await this.resolveEffectiveDisplayCurrency(userId, row), + supported: [...this.supportedDisplayCurrencies], + decimalPlaces: row.displayDecimalPlaces, + }; } private async resolveEffectiveDisplayCurrency( diff --git a/packages/core/src/pam/react.ts b/packages/core/src/pam/react.ts index ff2fe3a32..b09c99161 100644 --- a/packages/core/src/pam/react.ts +++ b/packages/core/src/pam/react.ts @@ -45,6 +45,7 @@ export { useUpdatePlayerProfile, useDisplayCurrency, useSetDisplayCurrency, + useSetDisplayDecimalPlaces, type PlayerProfile, type DisplayCurrencyInfo, } from './profile/react/profile.js'; diff --git a/packages/testing/src/__tests__/display-currency.e2e.test.ts b/packages/testing/src/__tests__/display-currency.e2e.test.ts index 953f92071..d712e1684 100644 --- a/packages/testing/src/__tests__/display-currency.e2e.test.ts +++ b/packages/testing/src/__tests__/display-currency.e2e.test.ts @@ -42,8 +42,13 @@ describe('GET /profile/display-currency', () => { const res = await player.get('/profile/display-currency'); expect(res.status).toBe(200); - const body = (await res.json()) as { currency: string; supported: string[] }; + const body = (await res.json()) as { + currency: string; + supported: string[]; + decimalPlaces: number | null; + }; expect(typeof body.currency).toBe('string'); + expect(body.decimalPlaces).toBeNull(); expect(body.supported).toContain('USD'); expect(body.supported).toContain('BTC'); }); @@ -81,6 +86,58 @@ describe('PUT /profile/display-currency', () => { }); }); +describe('PUT /profile/display-decimal-places', () => { + it('persists the pick, records an audit entry, and reflects it on the next read', async () => { + const res = await player.put('/profile/display-decimal-places', { decimalPlaces: 6 }); + + expect(res.status).toBe(200); + expect((await res.json()) as { decimalPlaces: number }).toMatchObject({ decimalPlaces: 6 }); + + const readBack = await player.get('/profile/display-currency'); + expect((await readBack.json()) as { decimalPlaces: number }).toMatchObject({ + decimalPlaces: 6, + }); + + const rows = await app.container + .get(DRIZZLE) + .db.select() + .from(auditLog) + .where( + and( + eq(auditLog.resourceId, playerId), + eq(auditLog.action, 'player.display_decimal_places.set'), + ), + ); + expect(rows.length).toBeGreaterThan(0); + }); + + it.each([19, -1, 2.5, '4'])( + 'rejects %j instead of writing it and leaves the stored pick alone', + async (decimalPlaces) => { + await player.put('/profile/display-decimal-places', { decimalPlaces: 6 }); + + const res = await player.put('/profile/display-decimal-places', { decimalPlaces }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + const after = await player.get('/profile/display-currency'); + expect((await after.json()) as { decimalPlaces: number }).toMatchObject({ + decimalPlaces: 6, + }); + }, + ); + + it('clears the pick when set to null', async () => { + const res = await player.put('/profile/display-decimal-places', { decimalPlaces: null }); + + expect(res.status).toBe(200); + const readBack = await player.get('/profile/display-currency'); + expect((await readBack.json()) as { decimalPlaces: null }).toMatchObject({ + decimalPlaces: null, + }); + }); +}); + describe('GET /exchange-rate/rates', () => { it('returns one entry per source currency, null quote when no rate is available', async () => { const res = await player.get('/exchange-rate/rates?to=USD&from[]=USD&from[]=BTC');