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