1
0
Fork 0
polaris-task-force/src/lib/banking/index.ts

246 lines
6.6 KiB
TypeScript

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;
}