Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions platforms/eCurrency-api/src/controllers/CurrencyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand Down Expand Up @@ -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,
Expand Down
28 changes: 25 additions & 3 deletions platforms/eCurrency-api/src/controllers/LedgerController.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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" });
Expand Down
15 changes: 15 additions & 0 deletions platforms/eCurrency-api/src/services/CurrencyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +7 to 16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for other "balances" query key usages
rg -n '["'"'"'"]balances' --type ts --type tsx -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Search for balancesQueryKey variable
rg -n 'balancesQueryKey' --type ts --type tsx -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Search for AccountContext type definition/usage
rg -n 'AccountContext' --type ts --type tsx -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Look for useQuery calls with balances
rg -n 'useQuery.*balances|balances.*useQuery' --type ts --type tsx -C 3

Repository: MetaState-Prototype-Project/prototype

Length of output: 108


🏁 Script executed:

#!/bin/bash
# First, find the specific file mentioned in the review
fd -e tsx -e ts | grep -i "add-currency-account-modal"

Repository: MetaState-Prototype-Project/prototype

Length of output: 162


🏁 Script executed:

#!/bin/bash
# Search for balances without specifying tsx type
rg 'balancesQueryKey' -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 1840


🏁 Script executed:

#!/bin/bash
# Search for AccountContext usage
rg 'AccountContext' -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 10028


🏁 Script executed:

#!/bin/bash
# Broader search for balances query patterns
rg '"balances"' -C 2

Repository: MetaState-Prototype-Project/prototype

Length of output: 3451


Fix query key structure mismatch and centralize AccountContext type

The query key inconsistency is a real issue. The balancesQueryKey in this modal uses a decomposed tuple structure ["balances", accountContext?.type, accountContext?.id], but other components (dashboard.tsx, transfer-modal.tsx) query balances with ["balances", accountContext] (the entire object as a single parameter). React Query won't match these keys, so:

  • Invalidating with balancesQueryKey here won't invalidate the balances query in the dashboard or transfer modal.
  • The modal's mutation success won't cascade cache updates to other screens showing balance data.

Additionally, when accountContext is undefined, the key becomes ["balances", undefined, undefined], creating yet another cache entry.

Also: The AccountContext type is defined locally and duplicated inline (as { type: "user" | "group"; id: string }) across at least 5 files (dashboard, currency-detail, user-menu-dropdown, account-context-switcher). Extract it to a shared types file to prevent divergence.

🤖 Prompt for AI Agents
In
platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx
around lines 7 to 16, the modal constructs a mismatched React Query key and
duplicates the AccountContext type: change balancesQueryKey usage to match the
rest of the app by using the whole accountContext object as the second tuple
element (e.g. ["balances", accountContext]) instead of decomposing into type/id,
and ensure you normalize undefined to null (use accountContext ?? null) so you
don't create ["balances", undefined] entries; also extract the local
AccountContext interface into a shared types file (e.g.
platforms/eCurrency/client/src/types/account-context.ts), export it, and replace
local inline type definitions across dashboard.tsx, transfer-modal.tsx,
currency-detail, user-menu-dropdown, account-context-switcher and this modal
with an import of the centralized AccountContext type so all components use the
exact same type.


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"],
Expand All @@ -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<string, string> = {};
if (isGroupContext && accountContext?.id) {
params.accountType = "group";
params.accountId = accountContext.id;
}
const response = await apiClient.get("/api/ledger/balance", { params });
return response.data;
},
});
Expand All @@ -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<string, string> = { 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);
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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;
},
Expand All @@ -30,6 +40,8 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
setDescription("");
setGroupId("");
setAllowNegative(false);
setMaxNegativeInput("");
setError(null);
onOpenChange(false);
},
});
Expand All @@ -52,9 +64,35 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
<form
onSubmit={(e) => {
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"
>
Expand Down Expand Up @@ -102,7 +140,10 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setAllowNegative(false)}
onClick={() => {
setAllowNegative(false);
setMaxNegativeInput("");
}}
className={`p-4 border-2 rounded-lg text-left transition-all ${
!allowNegative
? "border-primary bg-primary/5"
Expand Down Expand Up @@ -131,6 +172,31 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
</div>
</div>

{allowNegative && (
<div>
<label className="block text-sm font-medium mb-1">Max negative balance (absolute value)</label>
<input
type="number"
min={0}
max={MAX_NEGATIVE_LIMIT}
step={0.01}
value={maxNegativeInput}
onChange={(e) => setMaxNegativeInput(e.target.value)}
placeholder="Leave blank for no cap"
className="w-full px-3 py-2 border rounded-md"
/>
<p className="text-xs text-muted-foreground mt-1">
Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}).
</p>
</div>
)}

{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-md">
{error}
</div>
)}

<div className="flex gap-2 justify-end pt-2">
<button
type="button"
Expand Down
Loading