1
0
Fork 0

feat(banking): add configurable currency display names

Adds a Universal Currency group to Game Rules (name, symbol, singular, plural, denomination) with a CurrencyConfig reader in the banking lib that pluralizes labels and prefixes symbols; registers the game_rules migration.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 01:23:53 -04:00
parent f904522683
commit 8205c33768
6 changed files with 21073 additions and 25 deletions

View file

@ -17,6 +17,54 @@ export const GameRules: GlobalConfig = {
description: description:
'The base currency ("gold standard") used by all factions to pay for goods and services.', 'The base currency ("gold standard") used by all factions to pay for goods and services.',
}, },
{
label: "Universal Currency",
type: "collapsible",
admin: {
initCollapsed: true,
description:
"Display names used throughout the app. These do not change the accounting resource selected above.",
},
fields: [
{
name: "currencyName",
type: "text",
admin: {
description:
'General display name for the currency, e.g. "credit". Used when singular or plural names are not set.',
},
},
{
name: "currencySymbol",
type: "text",
admin: {
description: 'Optional symbol prefixed to amounts, e.g. "¢" or "$".',
},
},
{
name: "currencySingular",
type: "text",
admin: {
description: 'Singular amount label, e.g. "credit".',
},
},
{
name: "currencyPlural",
type: "text",
admin: {
description: 'Plural amount label, e.g. "credits".',
},
},
{
name: "currencyDenomination",
type: "text",
admin: {
description:
'Optional subunit name for future fractional displays, e.g. "cent". This does not affect stored values.',
},
},
],
},
}, },
{ {
name: "commandAssignments", name: "commandAssignments",

View file

@ -1,18 +1,58 @@
interface CurrencyLike { import { pluralize } from "@/lib/utils";
name?: string | null;
codeName?: string | null; export interface CurrencyLike {
readonly name?: string | null;
readonly codeName?: string | null;
} }
export function currencyLabel(currency?: CurrencyLike | number | null): string { export interface CurrencyConfig {
readonly name?: string | null;
readonly symbol?: string | null;
readonly singular?: string | null;
readonly plural?: string | null;
readonly denomination?: string | null;
readonly resourceName?: string | null;
}
type CurrencyDisplay = CurrencyLike | CurrencyConfig | number | null;
function isCurrencyConfig(currency: CurrencyLike | CurrencyConfig): currency is CurrencyConfig {
return (
"symbol" in currency ||
"singular" in currency ||
"plural" in currency ||
"denomination" in currency ||
"resourceName" in currency
);
}
export function currencyLabel(currency?: CurrencyDisplay, amount = 2): string {
if (typeof currency === "number") return ""; if (typeof currency === "number") return "";
if (!currency) return "units"; if (!currency) return "units";
return currency.name || currency.codeName || "units"; if (!isCurrencyConfig(currency)) return currency.name || currency.codeName || "units";
const configuredName = currency.name?.trim();
const singular =
currency.singular?.trim() || configuredName || currency.resourceName?.trim() || "unit";
if (amount === 1) return singular;
return (
currency.plural?.trim() ||
(currency.singular?.trim() || configuredName
? pluralize(amount, currency.singular?.trim() || configuredName || singular)
: currency.resourceName?.trim()) ||
"units"
);
} }
export function formatAmount(amount: number, currency?: CurrencyLike | number | null): string { export function formatAmount(amount: number, currency?: CurrencyDisplay): string {
const n = amount.toLocaleString(); const n = amount.toLocaleString();
const label = currencyLabel(currency); const label = currencyLabel(currency, amount);
return label ? `${n} ${label}` : n; const symbol =
currency && typeof currency === "object" && isCurrencyConfig(currency)
? currency.symbol?.trim()
: undefined;
const formatted = symbol ? `${symbol}${n}` : n;
return label ? `${formatted} ${label}` : formatted;
} }
export function formatDate(date: string | Date): string { export function formatDate(date: string | Date): string {

View file

@ -1,16 +1,11 @@
import type { PayloadRequest } from "payload"; import type { PayloadRequest } from "payload";
import type { BankAccount, BankTransaction } from "@/payload-types"; import type { BankAccount, BankTransaction, GameRule } from "@/payload-types";
import { currencyLabel, type CurrencyConfig } from "./format";
export type BankAccountType = "treasury" | "faction" | "personal"; export type BankAccountType = "treasury" | "faction" | "personal";
export type BankTransactionType = export type BankTransactionType =
| "deposit" "deposit" | "withdrawal" | "transfer" | "payment" | "fee" | "salary" | "adjustment";
| "withdrawal"
| "transfer"
| "payment"
| "fee"
| "salary"
| "adjustment";
export interface ApplyTransactionInput { export interface ApplyTransactionInput {
type: BankTransactionType; type: BankTransactionType;
@ -47,17 +42,46 @@ export async function getMainCurrencyId(payload: PayloadType): Promise<number |
} }
export async function getMainCurrencyName(payload: PayloadType): Promise<string | null> { export async function getMainCurrencyName(payload: PayloadType): Promise<string | null> {
const currencyId = await getMainCurrencyId(payload);
if (!currencyId) return null;
try { try {
const resource = (await payload.findByID({ return currencyLabel(await getCurrencyConfig(payload));
collection: "resources", } catch {
id: currencyId, return null;
}
}
export function currencyConfigFromGameRules(rules: GameRule): CurrencyConfig {
const resource = typeof rules.mainCurrency === "object" ? rules.mainCurrency : null;
return {
name: rules.currencyName?.trim() || null,
symbol: rules.currencySymbol?.trim() || null,
singular: rules.currencySingular?.trim() || null,
plural: rules.currencyPlural?.trim() || null,
denomination: rules.currencyDenomination?.trim() || null,
resourceName: resource?.name || resource?.codeName || null,
};
}
export async function getCurrencyConfig(payload: PayloadType): Promise<CurrencyConfig> {
const rules = await payload.findGlobal({
slug: "game-rules",
depth: 1,
overrideAccess: true,
});
return currencyConfigFromGameRules(rules);
}
/** Resolve the id of the open unit treasury account, if any. */
export async function getTreasuryAccountId(payload: PayloadType): Promise<number | null> {
try {
const result = (await payload.find({
collection: "bank-accounts",
where: { and: [{ accountType: { equals: "treasury" } }, { status: { equals: "open" } }] },
limit: 1,
depth: 0, depth: 0,
overrideAccess: true, overrideAccess: true,
})) as unknown as { name?: string | null; codeName?: string | null } | null; })) as unknown as { docs: { id: number }[] };
if (!resource) return null; return result.docs[0]?.id ?? null;
return resource.name || resource.codeName || null;
} catch { } catch {
return null; return null;
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" ADD COLUMN "currency_name" varchar;
ALTER TABLE "game_rules" ADD COLUMN "currency_symbol" varchar;
ALTER TABLE "game_rules" ADD COLUMN "currency_singular" varchar;
ALTER TABLE "game_rules" ADD COLUMN "currency_plural" varchar;
ALTER TABLE "game_rules" ADD COLUMN "currency_denomination" varchar;`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" DROP COLUMN "currency_name";
ALTER TABLE "game_rules" DROP COLUMN "currency_symbol";
ALTER TABLE "game_rules" DROP COLUMN "currency_singular";
ALTER TABLE "game_rules" DROP COLUMN "currency_plural";
ALTER TABLE "game_rules" DROP COLUMN "currency_denomination";`);
}

View file

@ -22,6 +22,7 @@ import * as migration_20260903_060000_add_radio_mode_stats from './20260903_0600
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts'; import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats'; import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections'; import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260906_045502 from './20260906_045502';
export const migrations = [ export const migrations = [
{ {
@ -142,6 +143,11 @@ export const migrations = [
{ {
up: migration_20260904_230838_add_wiki_collections.up, up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down, down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections' name: '20260904_230838_add_wiki_collections',
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: '20260906_045502',
}, },
]; ];