Skip to content

Commit 41741b5

Browse files
authored
feat: fix ecurrency bugs + UX improvments (#619)
1 parent dd226ce commit 41741b5

File tree

7 files changed

+184
-36
lines changed

7 files changed

+184
-36
lines changed

platforms/eCurrency-api/src/controllers/CurrencyController.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,33 @@ export class CurrencyController {
2121
return res.status(400).json({ error: "Name and groupId are required" });
2222
}
2323

24+
const allowNegativeFlag = Boolean(allowNegative);
25+
let normalizedMaxNegative: number | null = null;
26+
27+
if (maxNegativeBalance !== undefined && maxNegativeBalance !== null && maxNegativeBalance !== "") {
28+
const parsedValue = Number(maxNegativeBalance);
29+
if (Number.isNaN(parsedValue)) {
30+
return res.status(400).json({ error: "Invalid maxNegativeBalance value" });
31+
}
32+
if (parsedValue > 0) {
33+
return res.status(400).json({ error: "maxNegativeBalance must be zero or negative" });
34+
}
35+
if (parsedValue < -1_000_000_000) {
36+
return res.status(400).json({ error: "maxNegativeBalance exceeds allowed limit" });
37+
}
38+
normalizedMaxNegative = parsedValue;
39+
}
40+
41+
if (!allowNegativeFlag) {
42+
normalizedMaxNegative = null;
43+
}
44+
2445
const currency = await this.currencyService.createCurrency(
2546
name,
2647
groupId,
2748
req.user.id,
28-
allowNegative || false,
29-
maxNegativeBalance ?? null,
49+
allowNegativeFlag,
50+
normalizedMaxNegative,
3051
description
3152
);
3253

@@ -164,6 +185,14 @@ export class CurrencyController {
164185
if (parsedValue !== null && Number.isNaN(parsedValue)) {
165186
return res.status(400).json({ error: "Invalid value for maxNegativeBalance" });
166187
}
188+
if (parsedValue !== null) {
189+
if (parsedValue > 0) {
190+
return res.status(400).json({ error: "maxNegativeBalance must be zero or negative" });
191+
}
192+
if (parsedValue < -1_000_000_000) {
193+
return res.status(400).json({ error: "maxNegativeBalance exceeds allowed limit" });
194+
}
195+
}
167196

168197
const updated = await this.currencyService.updateMaxNegativeBalance(
169198
id,

platforms/eCurrency-api/src/controllers/LedgerController.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Request, Response } from "express";
22
import { LedgerService } from "../services/LedgerService";
33
import { AccountType } from "../database/entities/Ledger";
4+
import { GroupService } from "../services/GroupService";
45

56
export class LedgerController {
67
private ledgerService: LedgerService;
8+
private groupService: GroupService;
79

810
constructor() {
911
this.ledgerService = new LedgerService();
12+
this.groupService = new GroupService();
1013
}
1114

1215
getBalance = async (req: Request, res: Response) => {
@@ -292,16 +295,35 @@ export class LedgerController {
292295
return res.status(401).json({ error: "Authentication required" });
293296
}
294297

295-
const { currencyId } = req.body;
298+
const { currencyId, accountId, accountType } = req.body;
296299

297300
if (!currencyId) {
298301
return res.status(400).json({ error: "currencyId is required" });
299302
}
300303

304+
const finalAccountType: AccountType = accountType ? (accountType as AccountType) : AccountType.USER;
305+
if (!Object.values(AccountType).includes(finalAccountType)) {
306+
return res.status(400).json({ error: "Invalid accountType" });
307+
}
308+
309+
let finalAccountId: string | undefined =
310+
finalAccountType === AccountType.GROUP ? accountId : req.user.id;
311+
312+
if (!finalAccountId) {
313+
return res.status(400).json({ error: "accountId is required for group accounts" });
314+
}
315+
316+
if (
317+
finalAccountType === AccountType.GROUP &&
318+
!(await this.groupService.isGroupAdmin(finalAccountId, req.user.id))
319+
) {
320+
return res.status(403).json({ error: "Only group admins can manage group accounts" });
321+
}
322+
301323
await this.ledgerService.initializeAccount(
302324
currencyId,
303-
req.user.id,
304-
AccountType.USER
325+
finalAccountId,
326+
finalAccountType
305327
);
306328

307329
res.json({ message: "Account initialized successfully" });

platforms/eCurrency-api/src/services/CurrencyService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ export class CurrencyService {
3434
// Generate eName (UUID with @ prefix)
3535
const ename = `@${uuidv4()}`;
3636

37+
if (maxNegativeBalance !== null) {
38+
if (!allowNegative) {
39+
throw new Error("Cannot set max negative balance when negative balances are disabled");
40+
}
41+
if (maxNegativeBalance > 0) {
42+
throw new Error("Max negative balance must be zero or negative");
43+
}
44+
if (maxNegativeBalance < -1_000_000_000) {
45+
throw new Error("Max negative balance exceeds allowed limit");
46+
}
47+
}
48+
3749
const currency = this.currencyRepository.create({
3850
name,
3951
description,
@@ -110,6 +122,9 @@ export class CurrencyService {
110122
if (value > 0) {
111123
throw new Error("Max negative balance must be zero or negative");
112124
}
125+
if (value < -1_000_000_000) {
126+
throw new Error("Max negative balance exceeds allowed limit");
127+
}
113128
}
114129

115130
currency.maxNegativeBalance = value;

platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ import { apiClient } from "@/lib/apiClient";
44
import { X, Search, ChevronDown } from "lucide-react";
55
import { formatEName } from "@/lib/utils";
66

7+
interface AccountContext {
8+
type: "user" | "group";
9+
id: string;
10+
}
11+
712
interface AddCurrencyAccountModalProps {
813
open: boolean;
914
onOpenChange: (open: boolean) => void;
15+
accountContext?: AccountContext | null;
1016
}
1117

12-
export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurrencyAccountModalProps) {
18+
export default function AddCurrencyAccountModal({ open, onOpenChange, accountContext }: AddCurrencyAccountModalProps) {
1319
const [currencyId, setCurrencyId] = useState("");
1420
const [searchQuery, setSearchQuery] = useState("");
1521
const [isOpen, setIsOpen] = useState(false);
1622
const queryClient = useQueryClient();
23+
const isGroupContext = accountContext?.type === "group";
24+
const balancesQueryKey = ["balances", accountContext?.type, accountContext?.id];
1725

1826
const { data: currencies } = useQuery({
1927
queryKey: ["currencies"],
@@ -24,9 +32,14 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre
2432
});
2533

2634
const { data: balances } = useQuery({
27-
queryKey: ["balances"],
35+
queryKey: balancesQueryKey,
2836
queryFn: async () => {
29-
const response = await apiClient.get("/api/ledger/balance");
37+
const params: Record<string, string> = {};
38+
if (isGroupContext && accountContext?.id) {
39+
params.accountType = "group";
40+
params.accountId = accountContext.id;
41+
}
42+
const response = await apiClient.get("/api/ledger/balance", { params });
3043
return response.data;
3144
},
3245
});
@@ -48,12 +61,18 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre
4861
const selectedCurrency = availableCurrencies.find((c: any) => c.id === currencyId);
4962

5063
const initializeMutation = useMutation({
51-
mutationFn: async (currencyId: string) => {
52-
const response = await apiClient.post("/api/ledger/initialize", { currencyId });
64+
mutationFn: async () => {
65+
if (!currencyId) return;
66+
const payload: Record<string, string> = { currencyId };
67+
if (isGroupContext && accountContext?.id) {
68+
payload.accountType = "group";
69+
payload.accountId = accountContext.id;
70+
}
71+
const response = await apiClient.post("/api/ledger/initialize", payload);
5372
return response.data;
5473
},
5574
onSuccess: () => {
56-
queryClient.invalidateQueries({ queryKey: ["balances"] });
75+
queryClient.invalidateQueries({ queryKey: balancesQueryKey });
5776
setCurrencyId("");
5877
setSearchQuery("");
5978
setIsOpen(false);
@@ -86,7 +105,7 @@ export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurre
86105
onSubmit={(e) => {
87106
e.preventDefault();
88107
if (currencyId) {
89-
initializeMutation.mutate(currencyId);
108+
initializeMutation.mutate();
90109
}
91110
}}
92111
className="space-y-4"

platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,27 @@ interface CreateCurrencyModalProps {
99
groups: Array<{ id: string; name: string; isAdmin: boolean }>;
1010
}
1111

12+
const MAX_NEGATIVE_LIMIT = 1_000_000_000;
13+
1214
export default function CreateCurrencyModal({ open, onOpenChange, groups }: CreateCurrencyModalProps) {
1315
const [name, setName] = useState("");
1416
const [description, setDescription] = useState("");
1517
const [groupId, setGroupId] = useState("");
1618
const [allowNegative, setAllowNegative] = useState(false);
19+
const [maxNegativeInput, setMaxNegativeInput] = useState("");
20+
const [error, setError] = useState<string | null>(null);
1721
const queryClient = useQueryClient();
1822

1923
const adminGroups = groups.filter(g => g.isAdmin);
2024

2125
const createMutation = useMutation({
22-
mutationFn: async (data: { name: string; description?: string; groupId: string; allowNegative: boolean }) => {
26+
mutationFn: async (data: {
27+
name: string;
28+
description?: string;
29+
groupId: string;
30+
allowNegative: boolean;
31+
maxNegativeBalance: number | null;
32+
}) => {
2333
const response = await apiClient.post("/api/currencies", data);
2434
return response.data;
2535
},
@@ -30,6 +40,8 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
3040
setDescription("");
3141
setGroupId("");
3242
setAllowNegative(false);
43+
setMaxNegativeInput("");
44+
setError(null);
3345
onOpenChange(false);
3446
},
3547
});
@@ -52,9 +64,35 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
5264
<form
5365
onSubmit={(e) => {
5466
e.preventDefault();
55-
if (name && groupId) {
56-
createMutation.mutate({ name, description, groupId, allowNegative });
67+
setError(null);
68+
if (!name || !groupId) return;
69+
70+
let maxNegativeValue: number | null = null;
71+
if (allowNegative) {
72+
const trimmed = maxNegativeInput.trim();
73+
if (trimmed) {
74+
const magnitude = parseFloat(trimmed);
75+
if (Number.isNaN(magnitude) || magnitude < 0) {
76+
setError("Enter a valid non-negative number for max negative balance.");
77+
return;
78+
}
79+
if (magnitude > MAX_NEGATIVE_LIMIT) {
80+
setError(`Max negative cannot exceed ${MAX_NEGATIVE_LIMIT.toLocaleString()}.`);
81+
return;
82+
}
83+
maxNegativeValue = magnitude === 0 ? 0 : -Math.abs(magnitude);
84+
}
85+
} else {
86+
setMaxNegativeInput("");
5787
}
88+
89+
createMutation.mutate({
90+
name,
91+
description,
92+
groupId,
93+
allowNegative,
94+
maxNegativeBalance: maxNegativeValue,
95+
});
5896
}}
5997
className="space-y-4"
6098
>
@@ -102,7 +140,10 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
102140
<div className="grid grid-cols-2 gap-3">
103141
<button
104142
type="button"
105-
onClick={() => setAllowNegative(false)}
143+
onClick={() => {
144+
setAllowNegative(false);
145+
setMaxNegativeInput("");
146+
}}
106147
className={`p-4 border-2 rounded-lg text-left transition-all ${
107148
!allowNegative
108149
? "border-primary bg-primary/5"
@@ -131,6 +172,31 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
131172
</div>
132173
</div>
133174

175+
{allowNegative && (
176+
<div>
177+
<label className="block text-sm font-medium mb-1">Max negative balance (absolute value)</label>
178+
<input
179+
type="number"
180+
min={0}
181+
max={MAX_NEGATIVE_LIMIT}
182+
step={0.01}
183+
value={maxNegativeInput}
184+
onChange={(e) => setMaxNegativeInput(e.target.value)}
185+
placeholder="Leave blank for no cap"
186+
className="w-full px-3 py-2 border rounded-md"
187+
/>
188+
<p className="text-xs text-muted-foreground mt-1">
189+
Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}).
190+
</p>
191+
</div>
192+
)}
193+
194+
{error && (
195+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-md">
196+
{error}
197+
</div>
198+
)}
199+
134200
<div className="flex gap-2 justify-end pt-2">
135201
<button
136202
type="button"

0 commit comments

Comments
 (0)