Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/display-decimal-places.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/core/src/contracts/adapters/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/contracts/schemas/display-currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export const DisplayCurrencyInputSchema = CurrencyTickerInputSchema;

export type DisplayCurrency = z.infer<typeof DisplayCurrencyCodeSchema>;

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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<WalletReader>({}),
mock<ExchangeRateReader>({}),
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();
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/pam/profile/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UpdatePlayerProfileInputSchema,
DisplayCurrencyCodeSchema,
DisplayCurrencyInputSchema,
DisplayDecimalPlacesSchema,
} from '@openora/core/contracts';

// Player-facing self-profile contract. Caller resolved from the verified
Expand All @@ -18,6 +19,7 @@ export {
export const DisplayCurrencyInfoSchema = z.object({
currency: DisplayCurrencyCodeSchema,
supported: z.array(DisplayCurrencyCodeSchema),
decimalPlaces: DisplayDecimalPlacesSchema,
});
export type DisplayCurrencyInfo = z.infer<typeof DisplayCurrencyInfoSchema>;

Expand All @@ -26,6 +28,11 @@ export const SetDisplayCurrencyInputSchema = z.object({
});
export type SetDisplayCurrencyInput = z.infer<typeof SetDisplayCurrencyInputSchema>;

export const SetDisplayDecimalPlacesInputSchema = z.object({
decimalPlaces: DisplayDecimalPlacesSchema,
});
export type SetDisplayDecimalPlacesInput = z.infer<typeof SetDisplayDecimalPlacesInputSchema>;

export const profileContract = {
get: oc.route({ method: 'GET', path: '/profile' }).output(PlayerSchema),

Expand All @@ -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),
};
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading