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:
parent
f904522683
commit
8205c33768
6 changed files with 21073 additions and 25 deletions
|
|
@ -17,6 +17,54 @@ export const GameRules: GlobalConfig = {
|
|||
description:
|
||||
'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",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,58 @@
|
|||
interface CurrencyLike {
|
||||
name?: string | null;
|
||||
codeName?: string | null;
|
||||
import { pluralize } from "@/lib/utils";
|
||||
|
||||
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 (!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 label = currencyLabel(currency);
|
||||
return label ? `${n} ${label}` : n;
|
||||
const label = currencyLabel(currency, amount);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
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 BankTransactionType =
|
||||
| "deposit"
|
||||
| "withdrawal"
|
||||
| "transfer"
|
||||
| "payment"
|
||||
| "fee"
|
||||
| "salary"
|
||||
| "adjustment";
|
||||
"deposit" | "withdrawal" | "transfer" | "payment" | "fee" | "salary" | "adjustment";
|
||||
|
||||
export interface ApplyTransactionInput {
|
||||
type: BankTransactionType;
|
||||
|
|
@ -47,17 +42,46 @@ export async function getMainCurrencyId(payload: PayloadType): Promise<number |
|
|||
}
|
||||
|
||||
export async function getMainCurrencyName(payload: PayloadType): Promise<string | null> {
|
||||
const currencyId = await getMainCurrencyId(payload);
|
||||
if (!currencyId) return null;
|
||||
try {
|
||||
const resource = (await payload.findByID({
|
||||
collection: "resources",
|
||||
id: currencyId,
|
||||
return currencyLabel(await getCurrencyConfig(payload));
|
||||
} catch {
|
||||
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,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { name?: string | null; codeName?: string | null } | null;
|
||||
if (!resource) return null;
|
||||
return resource.name || resource.codeName || null;
|
||||
})) as unknown as { docs: { id: number }[] };
|
||||
return result.docs[0]?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
20911
src/migrations/20260906_045502.json
Normal file
20911
src/migrations/20260906_045502.json
Normal file
File diff suppressed because it is too large
Load diff
19
src/migrations/20260906_045502.ts
Normal file
19
src/migrations/20260906_045502.ts
Normal 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";`);
|
||||
}
|
||||
|
|
@ -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_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_20260906_045502 from './20260906_045502';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -142,6 +143,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260904_230838_add_wiki_collections.up,
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue