feat(banking): add banking service library with transaction engine
This commit is contained in:
parent
a440623d93
commit
4e0a906697
2 changed files with 262 additions and 0 deletions
20
src/lib/banking/format.ts
Normal file
20
src/lib/banking/format.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
242
src/lib/banking/index.ts
Normal file
242
src/lib/banking/index.ts
Normal file
|
|
@ -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<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
async function getAccountOrThrow(payload: PayloadType, id: number): Promise<BankAccount> {
|
||||||
|
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<number | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<BankAccount> {
|
||||||
|
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<BankAccount> {
|
||||||
|
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<BankTransaction> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue