diff --git a/src/app/(frontend)/logistics/banking/actions.ts b/src/app/(frontend)/logistics/banking/actions.ts new file mode 100644 index 0000000..9038dc4 --- /dev/null +++ b/src/app/(frontend)/logistics/banking/actions.ts @@ -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 { + 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>, + user: User, +): Promise { + 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> { + 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> { + 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> { + return moveFunds("deposit", undefined, accountId, amount, memo); +} + +export async function withdrawFunds( + accountId: number, + amount: number, + memo?: string, +): Promise> { + return moveFunds("withdrawal", accountId, undefined, amount, memo); +} + +export async function transferFunds( + fromAccountId: number, + toAccountId: number, + amount: number, + memo?: string, +): Promise> { + 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> { + 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 }; + } +} diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts index 423cd8f..e648efb 100644 --- a/src/collections/game/GameEventLogs.ts +++ b/src/collections/game/GameEventLogs.ts @@ -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 = { diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index fdb7395..a047669 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -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; } diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 215a1f8..86aec07 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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",