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: "Technologies", value: "technologies" },
|
||||||
{ label: "Maps", value: "maps" },
|
{ label: "Maps", value: "maps" },
|
||||||
{ label: "Shipments", value: "shipments" },
|
{ label: "Shipments", value: "shipments" },
|
||||||
|
{ label: "Bank Accounts", value: "bank-accounts" },
|
||||||
|
{ label: "Bank Transactions", value: "bank-transactions" },
|
||||||
|
{ label: "Ledger Entries", value: "ledger-entries" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ interface GameEventInput {
|
||||||
message: string;
|
message: string;
|
||||||
actor?: number;
|
actor?: number;
|
||||||
structure?: 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;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export const EventTypes = {
|
||||||
// Finance
|
// Finance
|
||||||
FinanceDeposit: "finance:deposit",
|
FinanceDeposit: "finance:deposit",
|
||||||
FinanceWithdraw: "finance:withdraw",
|
FinanceWithdraw: "finance:withdraw",
|
||||||
|
FinanceTransfer: "finance:transfer",
|
||||||
|
BankAccountCreate: "bank:account-create",
|
||||||
|
|
||||||
// Shipping
|
// Shipping
|
||||||
ShipmentCreate: "shipment:create",
|
ShipmentCreate: "shipment:create",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue