diff --git a/platforms/eCurrency-api/src/controllers/CurrencyController.ts b/platforms/eCurrency-api/src/controllers/CurrencyController.ts index 61b9f1b8..785e2a75 100644 --- a/platforms/eCurrency-api/src/controllers/CurrencyController.ts +++ b/platforms/eCurrency-api/src/controllers/CurrencyController.ts @@ -21,12 +21,33 @@ export class CurrencyController { return res.status(400).json({ error: "Name and groupId are required" }); } + const allowNegativeFlag = Boolean(allowNegative); + let normalizedMaxNegative: number | null = null; + + if (maxNegativeBalance !== undefined && maxNegativeBalance !== null && maxNegativeBalance !== "") { + const parsedValue = Number(maxNegativeBalance); + if (Number.isNaN(parsedValue)) { + return res.status(400).json({ error: "Invalid maxNegativeBalance value" }); + } + if (parsedValue > 0) { + return res.status(400).json({ error: "maxNegativeBalance must be zero or negative" }); + } + if (parsedValue < -1_000_000_000) { + return res.status(400).json({ error: "maxNegativeBalance exceeds allowed limit" }); + } + normalizedMaxNegative = parsedValue; + } + + if (!allowNegativeFlag) { + normalizedMaxNegative = null; + } + const currency = await this.currencyService.createCurrency( name, groupId, req.user.id, - allowNegative || false, - maxNegativeBalance ?? null, + allowNegativeFlag, + normalizedMaxNegative, description ); @@ -164,6 +185,14 @@ export class CurrencyController { if (parsedValue !== null && Number.isNaN(parsedValue)) { return res.status(400).json({ error: "Invalid value for maxNegativeBalance" }); } + if (parsedValue !== null) { + if (parsedValue > 0) { + return res.status(400).json({ error: "maxNegativeBalance must be zero or negative" }); + } + if (parsedValue < -1_000_000_000) { + return res.status(400).json({ error: "maxNegativeBalance exceeds allowed limit" }); + } + } const updated = await this.currencyService.updateMaxNegativeBalance( id, diff --git a/platforms/eCurrency-api/src/controllers/LedgerController.ts b/platforms/eCurrency-api/src/controllers/LedgerController.ts index 069a710b..812b7baa 100644 --- a/platforms/eCurrency-api/src/controllers/LedgerController.ts +++ b/platforms/eCurrency-api/src/controllers/LedgerController.ts @@ -1,12 +1,15 @@ import { Request, Response } from "express"; import { LedgerService } from "../services/LedgerService"; import { AccountType } from "../database/entities/Ledger"; +import { GroupService } from "../services/GroupService"; export class LedgerController { private ledgerService: LedgerService; + private groupService: GroupService; constructor() { this.ledgerService = new LedgerService(); + this.groupService = new GroupService(); } getBalance = async (req: Request, res: Response) => { @@ -292,16 +295,35 @@ export class LedgerController { return res.status(401).json({ error: "Authentication required" }); } - const { currencyId } = req.body; + const { currencyId, accountId, accountType } = req.body; if (!currencyId) { return res.status(400).json({ error: "currencyId is required" }); } + const finalAccountType: AccountType = accountType ? (accountType as AccountType) : AccountType.USER; + if (!Object.values(AccountType).includes(finalAccountType)) { + return res.status(400).json({ error: "Invalid accountType" }); + } + + let finalAccountId: string | undefined = + finalAccountType === AccountType.GROUP ? accountId : req.user.id; + + if (!finalAccountId) { + return res.status(400).json({ error: "accountId is required for group accounts" }); + } + + if ( + finalAccountType === AccountType.GROUP && + !(await this.groupService.isGroupAdmin(finalAccountId, req.user.id)) + ) { + return res.status(403).json({ error: "Only group admins can manage group accounts" }); + } + await this.ledgerService.initializeAccount( currencyId, - req.user.id, - AccountType.USER + finalAccountId, + finalAccountType ); res.json({ message: "Account initialized successfully" }); diff --git a/platforms/eCurrency-api/src/services/CurrencyService.ts b/platforms/eCurrency-api/src/services/CurrencyService.ts index 650d9969..053cb40c 100644 --- a/platforms/eCurrency-api/src/services/CurrencyService.ts +++ b/platforms/eCurrency-api/src/services/CurrencyService.ts @@ -34,6 +34,18 @@ export class CurrencyService { // Generate eName (UUID with @ prefix) const ename = `@${uuidv4()}`; + if (maxNegativeBalance !== null) { + if (!allowNegative) { + throw new Error("Cannot set max negative balance when negative balances are disabled"); + } + if (maxNegativeBalance > 0) { + throw new Error("Max negative balance must be zero or negative"); + } + if (maxNegativeBalance < -1_000_000_000) { + throw new Error("Max negative balance exceeds allowed limit"); + } + } + const currency = this.currencyRepository.create({ name, description, @@ -110,6 +122,9 @@ export class CurrencyService { if (value > 0) { throw new Error("Max negative balance must be zero or negative"); } + if (value < -1_000_000_000) { + throw new Error("Max negative balance exceeds allowed limit"); + } } currency.maxNegativeBalance = value; diff --git a/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx index a721956b..034a152e 100644 --- a/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx +++ b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx @@ -4,16 +4,24 @@ import { apiClient } from "@/lib/apiClient"; import { X, Search, ChevronDown } from "lucide-react"; import { formatEName } from "@/lib/utils"; +interface AccountContext { + type: "user" | "group"; + id: string; +} + interface AddCurrencyAccountModalProps { open: boolean; onOpenChange: (open: boolean) => void; + accountContext?: AccountContext | null; } -export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurrencyAccountModalProps) { +export default function AddCurrencyAccountModal({ open, onOpenChange, accountContext }: AddCurrencyAccountModalProps) { const [currencyId, setCurrencyId] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); const queryClient = useQueryClient(); + const isGroupContext = accountContext?.type === "group"; + const balancesQueryKey = ["balances", accountContext?.type, accountContext?.id]; const { data: currencies } = useQuery({ queryKey: ["currencies"], @@ -24,9 +32,14 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre }); const { data: balances } = useQuery({ - queryKey: ["balances"], + queryKey: balancesQueryKey, queryFn: async () => { - const response = await apiClient.get("/api/ledger/balance"); + const params: Record = {}; + if (isGroupContext && accountContext?.id) { + params.accountType = "group"; + params.accountId = accountContext.id; + } + const response = await apiClient.get("/api/ledger/balance", { params }); return response.data; }, }); @@ -48,12 +61,18 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre const selectedCurrency = availableCurrencies.find((c: any) => c.id === currencyId); const initializeMutation = useMutation({ - mutationFn: async (currencyId: string) => { - const response = await apiClient.post("/api/ledger/initialize", { currencyId }); + mutationFn: async () => { + if (!currencyId) return; + const payload: Record = { currencyId }; + if (isGroupContext && accountContext?.id) { + payload.accountType = "group"; + payload.accountId = accountContext.id; + } + const response = await apiClient.post("/api/ledger/initialize", payload); return response.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["balances"] }); + queryClient.invalidateQueries({ queryKey: balancesQueryKey }); setCurrencyId(""); setSearchQuery(""); setIsOpen(false); @@ -86,7 +105,7 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre onSubmit={(e) => { e.preventDefault(); if (currencyId) { - initializeMutation.mutate(currencyId); + initializeMutation.mutate(); } }} className="space-y-4" diff --git a/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx b/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx index 6020cb91..5e740da4 100644 --- a/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx +++ b/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx @@ -9,17 +9,27 @@ interface CreateCurrencyModalProps { groups: Array<{ id: string; name: string; isAdmin: boolean }>; } +const MAX_NEGATIVE_LIMIT = 1_000_000_000; + export default function CreateCurrencyModal({ open, onOpenChange, groups }: CreateCurrencyModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [groupId, setGroupId] = useState(""); const [allowNegative, setAllowNegative] = useState(false); + const [maxNegativeInput, setMaxNegativeInput] = useState(""); + const [error, setError] = useState(null); const queryClient = useQueryClient(); const adminGroups = groups.filter(g => g.isAdmin); const createMutation = useMutation({ - mutationFn: async (data: { name: string; description?: string; groupId: string; allowNegative: boolean }) => { + mutationFn: async (data: { + name: string; + description?: string; + groupId: string; + allowNegative: boolean; + maxNegativeBalance: number | null; + }) => { const response = await apiClient.post("/api/currencies", data); return response.data; }, @@ -30,6 +40,8 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea setDescription(""); setGroupId(""); setAllowNegative(false); + setMaxNegativeInput(""); + setError(null); onOpenChange(false); }, }); @@ -52,9 +64,35 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
{ e.preventDefault(); - if (name && groupId) { - createMutation.mutate({ name, description, groupId, allowNegative }); + setError(null); + if (!name || !groupId) return; + + let maxNegativeValue: number | null = null; + if (allowNegative) { + const trimmed = maxNegativeInput.trim(); + if (trimmed) { + const magnitude = parseFloat(trimmed); + if (Number.isNaN(magnitude) || magnitude < 0) { + setError("Enter a valid non-negative number for max negative balance."); + return; + } + if (magnitude > MAX_NEGATIVE_LIMIT) { + setError(`Max negative cannot exceed ${MAX_NEGATIVE_LIMIT.toLocaleString()}.`); + return; + } + maxNegativeValue = magnitude === 0 ? 0 : -Math.abs(magnitude); + } + } else { + setMaxNegativeInput(""); } + + createMutation.mutate({ + name, + description, + groupId, + allowNegative, + maxNegativeBalance: maxNegativeValue, + }); }} className="space-y-4" > @@ -102,7 +140,10 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
+ {allowNegative && ( +
+ + setMaxNegativeInput(e.target.value)} + placeholder="Leave blank for no cap" + className="w-full px-3 py-2 border rounded-md" + /> +

+ Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}). +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
diff --git a/platforms/eCurrency/client/src/pages/dashboard.tsx b/platforms/eCurrency/client/src/pages/dashboard.tsx index 370b8586..fb01c3d0 100644 --- a/platforms/eCurrency/client/src/pages/dashboard.tsx +++ b/platforms/eCurrency/client/src/pages/dashboard.tsx @@ -328,7 +328,11 @@ export default function Dashboard() { onOpenChange={setCreateCurrencyOpen} groups={groups || []} /> - + {