feat(banking): add banking server actions and event logging
This commit is contained in:
parent
4e0a906697
commit
ee87bd3023
4 changed files with 310 additions and 1 deletions
304
src/app/(frontend)/logistics/banking/actions.ts
Normal file
304
src/app/(frontend)/logistics/banking/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue