diff --git a/src/lib/banking/format.ts b/src/lib/banking/format.ts new file mode 100644 index 0000000..08daa09 --- /dev/null +++ b/src/lib/banking/format.ts @@ -0,0 +1,20 @@ +interface CurrencyLike { + name?: string | null; + codeName?: string | null; +} + +export function currencyLabel(currency?: CurrencyLike | number | null): string { + if (typeof currency === "number") return ""; + if (!currency) return "units"; + return currency.name || currency.codeName || "units"; +} + +export function formatAmount(amount: number, currency?: CurrencyLike | number | null): string { + const n = amount.toLocaleString(); + const label = currencyLabel(currency); + return label ? `${n} ${label}` : n; +} + +export function formatDate(date: string | Date): string { + return new Date(date).toLocaleString(); +} diff --git a/src/lib/banking/index.ts b/src/lib/banking/index.ts new file mode 100644 index 0000000..30173a2 --- /dev/null +++ b/src/lib/banking/index.ts @@ -0,0 +1,242 @@ +import type { Payload } from "payload"; +import type { BankAccount, BankTransaction } from "@/payload-types"; + +export type BankAccountType = "treasury" | "faction" | "personal"; + +export type BankTransactionType = + | "deposit" + | "withdrawal" + | "transfer" + | "payment" + | "fee" + | "salary" + | "adjustment"; + +export interface ApplyTransactionInput { + type: BankTransactionType; + fromAccountId?: number; + toAccountId?: number; + amount: number; + fee?: number; + memo?: string; + actorId?: number; +} + +type PayloadType = Awaited>; + +async function getAccountOrThrow(payload: PayloadType, id: number): Promise { + const account = (await payload + .findByID({ collection: "bank-accounts", id, depth: 0, overrideAccess: true }) + .catch(() => null)) as unknown as BankAccount | null; + if (!account) throw new Error("Account not found."); + return account; +} + +export async function getMainCurrencyId(payload: PayloadType): Promise { + try { + const gameRules = await payload.findGlobal({ slug: "game-rules", depth: 0 }); + const currency = gameRules.mainCurrency; + if (typeof currency === "number") return currency; + if (currency && typeof currency === "object" && "id" in currency) { + return (currency.id as number) ?? null; + } + return null; + } catch { + return null; + } +} + +export async function getMainCurrencyName(payload: PayloadType): Promise { + const currencyId = await getMainCurrencyId(payload); + if (!currencyId) return null; + try { + const resource = (await payload.findByID({ + collection: "resources", + id: currencyId, + depth: 0, + overrideAccess: true, + })) as unknown as { name?: string | null; codeName?: string | null } | null; + if (!resource) return null; + return resource.name || resource.codeName || null; + } catch { + return null; + } +} + +export async function createAccount( + payload: PayloadType, + input: { + name: string; + accountType: BankAccountType; + ownerFactionId?: number; + ownerUserId?: number; + }, +): Promise { + const { name, accountType, ownerFactionId, ownerUserId } = input; + const currencyId = await getMainCurrencyId(payload); + if (!currencyId) { + throw new Error("No main currency is set in Game Rules."); + } + return (await payload.create({ + collection: "bank-accounts", + data: { + name, + accountType, + ownerFaction: ownerFactionId ?? null, + ownerUser: ownerUserId ?? null, + currency: currencyId, + balance: 0, + status: "open", + }, + overrideAccess: true, + depth: 0, + })) as unknown as BankAccount; +} + +export async function ensurePersonalAccount(payload: PayloadType, userId: number): Promise { + const existing = (await payload.find({ + collection: "bank-accounts", + where: { + and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: userId } }], + }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: BankAccount[] }; + if (existing.docs.length > 0) return existing.docs[0]; + + const user = (await payload.findByID({ + collection: "users", + id: userId, + depth: 0, + overrideAccess: true, + }).catch(() => null)) as unknown as { username?: string; displayName?: string } | null; + const label = user?.displayName || user?.username || `User ${userId}`; + + return createAccount(payload, { + name: `${label}'s Account`, + accountType: "personal", + ownerUserId: userId, + }); +} + +export async function applyTransaction( + payload: PayloadType, + input: ApplyTransactionInput, +): Promise { + const { type, fromAccountId, toAccountId, amount, fee = 0, memo, actorId } = input; + + if (!fromAccountId && !toAccountId) { + throw new Error("A transaction must involve at least one account."); + } + if (amount <= 0) { + throw new Error("Amount must be greater than zero."); + } + if (type === "transfer" && (!fromAccountId || !toAccountId)) { + throw new Error("A transfer requires both a source and a destination account."); + } + + const totalDebit = amount + fee; + + const fromAccount = fromAccountId ? await getAccountOrThrow(payload, fromAccountId) : null; + const toAccount = toAccountId ? await getAccountOrThrow(payload, toAccountId) : null; + + for (const account of [fromAccount, toAccount]) { + if (account && account.status === "closed") { + throw new Error(`Account "${account.name}" is closed.`); + } + if (account && account.status === "frozen") { + throw new Error(`Account "${account.name}" is frozen.`); + } + } + + if (fromAccount && (fromAccount.balance ?? 0) < totalDebit) { + throw new Error( + `Insufficient funds in "${fromAccount.name}". Available: ${(fromAccount.balance ?? 0).toLocaleString()}, needed: ${totalDebit.toLocaleString()}.`, + ); + } + + const timestamp = new Date().toISOString(); + + const transaction = (await payload.create({ + collection: "bank-transactions", + data: { + transactionNumber: "", + type, + fromAccount: fromAccountId ?? null, + toAccount: toAccountId ?? null, + amount, + fee: fee ?? 0, + memo: memo ?? null, + actor: actorId ?? null, + status: "completed", + timestamp, + }, + overrideAccess: true, + depth: 0, + })) as unknown as BankTransaction; + + const fromNewBalance = fromAccount ? (fromAccount.balance ?? 0) - totalDebit : 0; + const toNewBalance = toAccount ? (toAccount.balance ?? 0) + amount : 0; + + const entries: { + account: number; + transaction: number; + type: BankTransactionType; + amount: number; + balanceAfter: number; + memo: string; + timestamp: string; + }[] = []; + + if (fromAccount) { + entries.push({ + account: fromAccount.id, + transaction: transaction.id, + type, + amount: -totalDebit, + balanceAfter: fromNewBalance, + memo: memo ?? `${type} — out`, + timestamp, + }); + } + if (toAccount) { + entries.push({ + account: toAccount.id, + transaction: transaction.id, + type, + amount, + balanceAfter: toNewBalance, + memo: memo ?? `${type} — in`, + timestamp, + }); + } + + for (const entry of entries) { + await payload.create({ + collection: "ledger-entries", + data: entry, + overrideAccess: true, + depth: 0, + }); + } + + if (fromAccount) { + await payload.update({ + collection: "bank-accounts", + id: fromAccount.id, + data: { balance: fromNewBalance }, + overrideAccess: true, + }); + } + if (toAccount) { + await payload.update({ + collection: "bank-accounts", + id: toAccount.id, + data: { balance: toNewBalance }, + overrideAccess: true, + }); + } + + return transaction; +}