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:
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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_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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue