1
0
Fork 0

feat(banking): add banking server actions and event logging

This commit is contained in:
Jason Fraley 2026-08-02 04:10:37 -04:00
parent 4e0a906697
commit ee87bd3023
4 changed files with 310 additions and 1 deletions

View file

@ -0,0 +1,304 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
applyTransaction,
createAccount,
ensurePersonalAccount,
getMainCurrencyName,
} from "@/lib/banking";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function isBankingManager(
payload: Awaited<ReturnType<typeof getPayload>>,
user: User,
): Promise<boolean> {
if (hasRoles(["admin", "developer"], user)) return true;
return hasLogisticsQualification(payload, user);
}
export async function createBankAccount(input: {
name: string;
accountType: "treasury" | "faction" | "personal";
ownerFactionId?: number;
ownerUserId?: number;
}): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
const manager = await isBankingManager(payload, user);
if (input.accountType === "personal") {
const ownerId = input.ownerUserId ?? (user.id as number);
if (!manager && ownerId !== user.id) {
return {
success: false,
error: "You can only create a personal account for yourself.",
};
}
const existing = await payload.find({
collection: "bank-accounts",
where: {
and: [
{ accountType: { equals: "personal" } },
{ ownerUser: { equals: ownerId } },
],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) {
return {
success: false,
error: "This user already has a personal account.",
};
}
const account = await createAccount(payload, {
name: input.name,
accountType: "personal",
ownerUserId: ownerId,
});
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created personal account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: "personal", ownerUserId: ownerId },
});
return { success: true, data: account.id };
}
if (!manager) {
return {
success: false,
error:
"Insufficient permissions. Logistics personnel or higher can manage unit accounts.",
};
}
if (input.accountType === "faction" && !input.ownerFactionId) {
return {
success: false,
error: "A faction account requires an owner faction.",
};
}
const account = await createAccount(payload, {
name: input.name,
accountType: input.accountType,
ownerFactionId: input.ownerFactionId,
});
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created ${input.accountType} account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: input.accountType, ownerFactionId: input.ownerFactionId },
});
return { success: true, data: account.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function ensureMyAccount(): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
const account = await ensurePersonalAccount(payload, user.id as number);
await emitGameEvent(payload, {
type: EventTypes.BankAccountCreate,
message: `Created personal account "${account.name}"`,
actor: user.id,
targetCollection: "bank-accounts",
targetId: account.id,
data: { accountType: "personal", ownerUserId: user.id },
});
return { success: true, data: account.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function depositFunds(
accountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("deposit", undefined, accountId, amount, memo);
}
export async function withdrawFunds(
accountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("withdrawal", accountId, undefined, amount, memo);
}
export async function transferFunds(
fromAccountId: number,
toAccountId: number,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
return moveFunds("transfer", fromAccountId, toAccountId, amount, memo);
}
async function moveFunds(
type: "deposit" | "withdrawal" | "transfer",
fromAccountId: number | undefined,
toAccountId: number | undefined,
amount: number,
memo?: string,
): Promise<ActionResult<number>> {
try {
const { payload, user } = await authenticate();
if (!hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions." };
}
if (!amount || amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const manager = await isBankingManager(payload, user);
const accountChecks: { id?: number; label: string }[] = [];
if (fromAccountId) accountChecks.push({ id: fromAccountId, label: "source" });
if (toAccountId) accountChecks.push({ id: toAccountId, label: "destination" });
for (const check of accountChecks) {
const account = (await payload
.findByID({
collection: "bank-accounts",
id: check.id as number,
depth: 0,
overrideAccess: true,
})
.catch(() => null)) as unknown as {
id: number;
accountType: string;
ownerUser?: number | { id: number } | null;
status: string;
} | null;
if (!account) {
return { success: false, error: `${check.label} account not found.` };
}
if (account.status === "closed") {
return { success: false, error: `${check.label} account is closed.` };
}
if (account.accountType === "personal") {
const ownerId =
typeof account.ownerUser === "object"
? account.ownerUser?.id
: account.ownerUser;
if (!manager && ownerId !== user.id) {
return {
success: false,
error: "You can only move funds on your own personal account.",
};
}
} else if (!manager) {
return {
success: false,
error:
"Insufficient permissions. Logistics personnel or higher can move unit funds.",
};
}
}
if (type === "transfer" && (!fromAccountId || !toAccountId)) {
return {
success: false,
error: "A transfer requires both a source and a destination account.",
};
}
if (type === "deposit" && !toAccountId) {
return { success: false, error: "A deposit requires a destination account." };
}
if (type === "withdrawal" && !fromAccountId) {
return { success: false, error: "A withdrawal requires a source account." };
}
const transaction = await applyTransaction(payload, {
type,
fromAccountId,
toAccountId,
amount,
memo,
actorId: user.id as number,
});
const currency = await getMainCurrencyName(payload);
const suffix = currency ? ` ${currency}` : "";
const amountLabel = `${amount.toLocaleString()}${suffix}`;
const eventType =
type === "deposit"
? EventTypes.FinanceDeposit
: type === "withdrawal"
? EventTypes.FinanceWithdraw
: EventTypes.FinanceTransfer;
const eventMessage =
type === "deposit"
? `Deposited ${amountLabel} into account #${toAccountId}`
: type === "withdrawal"
? `Withdrew ${amountLabel} from account #${fromAccountId}`
: `Transferred ${amountLabel} from account #${fromAccountId} to account #${toAccountId}`;
await emitGameEvent(payload, {
type: eventType,
message: eventMessage,
actor: user.id,
targetCollection: "bank-transactions",
targetId: transaction.id,
data: { fromAccountId, toAccountId, amount, type },
});
return { success: true, data: transaction.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -15,6 +15,9 @@ const TARGET_COLLECTIONS = [
{ label: "Technologies", value: "technologies" },
{ label: "Maps", value: "maps" },
{ label: "Shipments", value: "shipments" },
{ label: "Bank Accounts", value: "bank-accounts" },
{ label: "Bank Transactions", value: "bank-transactions" },
{ label: "Ledger Entries", value: "ledger-entries" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -5,7 +5,7 @@ interface GameEventInput {
message: string;
actor?: number;
structure?: number;
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments";
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -20,6 +20,8 @@ export const EventTypes = {
// Finance
FinanceDeposit: "finance:deposit",
FinanceWithdraw: "finance:withdraw",
FinanceTransfer: "finance:transfer",
BankAccountCreate: "bank:account-create",
// Shipping
ShipmentCreate: "shipment:create",