Skip to content

Commit dd226ce

Browse files
authored
feat: Add max negative caps and enforce ledger bounds (#618)
1 parent dbefb26 commit dd226ce

7 files changed

Lines changed: 238 additions & 10 deletions

File tree

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class CurrencyController {
1515
return res.status(401).json({ error: "Authentication required" });
1616
}
1717

18-
const { name, description, groupId, allowNegative } = req.body;
18+
const { name, description, groupId, allowNegative, maxNegativeBalance } = req.body;
1919

2020
if (!name || !groupId) {
2121
return res.status(400).json({ error: "Name and groupId are required" });
@@ -26,6 +26,7 @@ export class CurrencyController {
2626
groupId,
2727
req.user.id,
2828
allowNegative || false,
29+
maxNegativeBalance ?? null,
2930
description
3031
);
3132

@@ -36,6 +37,7 @@ export class CurrencyController {
3637
ename: currency.ename,
3738
groupId: currency.groupId,
3839
allowNegative: currency.allowNegative,
40+
maxNegativeBalance: currency.maxNegativeBalance,
3941
createdBy: currency.createdBy,
4042
createdAt: currency.createdAt,
4143
updatedAt: currency.updatedAt,
@@ -58,6 +60,7 @@ export class CurrencyController {
5860
ename: currency.ename,
5961
groupId: currency.groupId,
6062
allowNegative: currency.allowNegative,
63+
maxNegativeBalance: currency.maxNegativeBalance,
6164
createdBy: currency.createdBy,
6265
createdAt: currency.createdAt,
6366
updatedAt: currency.updatedAt,
@@ -84,6 +87,7 @@ export class CurrencyController {
8487
ename: currency.ename,
8588
groupId: currency.groupId,
8689
allowNegative: currency.allowNegative,
90+
maxNegativeBalance: currency.maxNegativeBalance,
8791
createdBy: currency.createdBy,
8892
createdAt: currency.createdAt,
8993
updatedAt: currency.updatedAt,
@@ -105,6 +109,7 @@ export class CurrencyController {
105109
ename: currency.ename,
106110
groupId: currency.groupId,
107111
allowNegative: currency.allowNegative,
112+
maxNegativeBalance: currency.maxNegativeBalance,
108113
createdBy: currency.createdBy,
109114
createdAt: currency.createdAt,
110115
updatedAt: currency.updatedAt,
@@ -144,5 +149,47 @@ export class CurrencyController {
144149
res.status(500).json({ error: "Internal server error" });
145150
}
146151
};
152+
153+
updateMaxNegativeBalance = async (req: Request, res: Response) => {
154+
try {
155+
if (!req.user) {
156+
return res.status(401).json({ error: "Authentication required" });
157+
}
158+
159+
const { id } = req.params;
160+
const { value } = req.body;
161+
162+
// Allow null to clear; otherwise must be a number
163+
const parsedValue = value === null || value === undefined ? null : Number(value);
164+
if (parsedValue !== null && Number.isNaN(parsedValue)) {
165+
return res.status(400).json({ error: "Invalid value for maxNegativeBalance" });
166+
}
167+
168+
const updated = await this.currencyService.updateMaxNegativeBalance(
169+
id,
170+
parsedValue,
171+
req.user.id
172+
);
173+
174+
res.status(200).json({
175+
id: updated.id,
176+
name: updated.name,
177+
description: updated.description,
178+
ename: updated.ename,
179+
groupId: updated.groupId,
180+
allowNegative: updated.allowNegative,
181+
maxNegativeBalance: updated.maxNegativeBalance,
182+
createdBy: updated.createdBy,
183+
createdAt: updated.createdAt,
184+
updatedAt: updated.updatedAt,
185+
});
186+
} catch (error: any) {
187+
console.error("Error updating max negative balance:", error);
188+
if (error.message.includes("Only group admins") || error.message.includes("not found") || error.message.includes("Cannot set max negative") || error.message.includes("Max negative")) {
189+
return res.status(400).json({ error: error.message });
190+
}
191+
res.status(500).json({ error: "Internal server error" });
192+
}
193+
};
147194
}
148195

platforms/eCurrency-api/src/database/entities/Currency.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export class Currency {
3636
@Column({ default: false })
3737
allowNegative!: boolean;
3838

39+
@Column("decimal", { precision: 18, scale: 2, nullable: true })
40+
maxNegativeBalance!: number | null;
41+
3942
@Column()
4043
createdBy!: string;
4144

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class Migration1765784749012 implements MigrationInterface {
4+
name = 'Migration1765784749012'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "currencies" ADD "maxNegativeBalance" numeric(18,2)`);
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(`ALTER TABLE "currencies" DROP COLUMN "maxNegativeBalance"`);
12+
}
13+
14+
}

platforms/eCurrency-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ app.get("/api/currencies", currencyController.getAllCurrencies);
112112
app.get("/api/currencies/:id", currencyController.getCurrencyById);
113113
app.get("/api/currencies/group/:groupId", currencyController.getCurrenciesByGroup);
114114
app.post("/api/currencies/:id/mint", authGuard, currencyController.mintCurrency);
115+
app.patch("/api/currencies/:id/max-negative", authGuard, currencyController.updateMaxNegativeBalance);
115116

116117
// Ledger routes
117118
app.get("/api/ledger/balance", authGuard, ledgerController.getBalance);

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class CurrencyService {
2222
groupId: string,
2323
createdBy: string,
2424
allowNegative: boolean = false,
25+
maxNegativeBalance: number | null = null,
2526
description?: string
2627
): Promise<Currency> {
2728
// Verify user is group admin
@@ -40,6 +41,7 @@ export class CurrencyService {
4041
groupId,
4142
createdBy,
4243
allowNegative,
44+
maxNegativeBalance,
4345
});
4446

4547
const savedCurrency = await this.currencyRepository.save(currency);
@@ -82,6 +84,38 @@ export class CurrencyService {
8284
});
8385
}
8486

87+
async updateMaxNegativeBalance(
88+
currencyId: string,
89+
value: number | null,
90+
requestedBy: string
91+
): Promise<Currency> {
92+
const currency = await this.getCurrencyById(currencyId);
93+
if (!currency) {
94+
throw new Error("Currency not found");
95+
}
96+
97+
const isAdmin = await this.groupService.isGroupAdmin(currency.groupId, requestedBy);
98+
if (!isAdmin) {
99+
throw new Error("Only group admins can update max negative balance");
100+
}
101+
102+
if (!currency.allowNegative) {
103+
throw new Error("Cannot set max negative balance when negative balances are not allowed");
104+
}
105+
106+
if (value !== null) {
107+
if (Number.isNaN(value)) {
108+
throw new Error("Invalid max negative balance value");
109+
}
110+
if (value > 0) {
111+
throw new Error("Max negative balance must be zero or negative");
112+
}
113+
}
114+
115+
currency.maxNegativeBalance = value;
116+
return await this.currencyRepository.save(currency);
117+
}
118+
85119
async mintCurrency(
86120
currencyId: string,
87121
amount: number,

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,13 @@ export class LedgerService {
4040
senderAccountId?: string,
4141
senderAccountType?: AccountType,
4242
receiverAccountId?: string,
43-
receiverAccountType?: AccountType
43+
receiverAccountType?: AccountType,
44+
existingBalance?: number
4445
): Promise<Ledger> {
4546
// Get current balance
46-
const currentBalance = await this.getAccountBalance(currencyId, accountId, accountType);
47+
const currentBalance = existingBalance !== undefined
48+
? existingBalance
49+
: await this.getAccountBalance(currencyId, accountId, accountType);
4750

4851
// Calculate new balance
4952
const newBalance = type === LedgerType.CREDIT
@@ -90,11 +93,17 @@ export class LedgerService {
9093
throw new Error("Currency not found");
9194
}
9295

93-
// Only check balance if negative balances are not allowed
94-
if (!currency.allowNegative) {
95-
const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType);
96-
if (currentBalance < amount) {
97-
throw new Error("Insufficient balance. This currency does not allow negative balances.");
96+
const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType);
97+
98+
// Validate debit bounds
99+
if (!currency.allowNegative && currentBalance < amount) {
100+
throw new Error("Insufficient balance. This currency does not allow negative balances.");
101+
}
102+
103+
if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) {
104+
const newBalance = currentBalance - amount;
105+
if (newBalance < Number(currency.maxNegativeBalance)) {
106+
throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`);
98107
}
99108
}
100109

@@ -110,7 +119,8 @@ export class LedgerService {
110119
fromAccountId, // sender
111120
fromAccountType, // sender type
112121
toAccountId, // receiver
113-
toAccountType // receiver type
122+
toAccountType, // receiver type
123+
currentBalance
114124
);
115125

116126
// Create credit entry (to receiver's account)

platforms/eCurrency/client/src/pages/currency-detail.tsx

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from "@tanstack/react-query";
1+
import { useQuery, useQueryClient } from "@tanstack/react-query";
22
import { useLocation, useRoute } from "wouter";
33
import { apiClient } from "../lib/apiClient";
44
import { useAuth } from "../hooks/useAuth";
@@ -15,12 +15,17 @@ export default function CurrencyDetail() {
1515
const [, params] = useRoute("/currency/:currencyId");
1616
const [, setLocation] = useLocation();
1717
const { user } = useAuth();
18+
const queryClient = useQueryClient();
1819
const [transferOpen, setTransferOpen] = useState(false);
1920
const [mintOpen, setMintOpen] = useState(false);
2021
const [selectedTransactionId, setSelectedTransactionId] = useState<string | null>(null);
2122
const [transactionOffset, setTransactionOffset] = useState(0);
2223
const [allTransactions, setAllTransactions] = useState<any[]>([]);
2324
const PAGE_SIZE = 10;
25+
const MAX_NEGATIVE_SLIDER = 1_000_000;
26+
const [maxNegativeInput, setMaxNegativeInput] = useState<string>("");
27+
const [maxNegativeSaving, setMaxNegativeSaving] = useState(false);
28+
const [maxNegativeError, setMaxNegativeError] = useState<string | null>(null);
2429

2530
// Load account context from localStorage
2631
const [accountContext, setAccountContext] = useState<{ type: "user" | "group"; id: string } | null>(() => {
@@ -48,6 +53,16 @@ export default function CurrencyDetail() {
4853
enabled: !!currencyId,
4954
});
5055

56+
useEffect(() => {
57+
if (currency) {
58+
if (currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) {
59+
setMaxNegativeInput(Math.abs(Number(currency.maxNegativeBalance)).toString());
60+
} else {
61+
setMaxNegativeInput("");
62+
}
63+
}
64+
}, [currency]);
65+
5166
const { data: accountDetails } = useQuery({
5267
queryKey: ["accountDetails", currencyId, accountContext],
5368
queryFn: async () => {
@@ -177,6 +192,40 @@ export default function CurrencyDetail() {
177192

178193
const isAdminOfCurrency = currency && groups?.some((g: any) => g.id === currency.groupId && g.isAdmin);
179194

195+
const saveMaxNegative = async () => {
196+
if (!currencyId) return;
197+
setMaxNegativeError(null);
198+
setMaxNegativeSaving(true);
199+
try {
200+
const trimmed = maxNegativeInput.trim();
201+
const isClearing = trimmed === "";
202+
let payloadValue: number | null = null;
203+
204+
if (!isClearing) {
205+
const magnitude = parseFloat(trimmed);
206+
if (Number.isNaN(magnitude) || magnitude < 0) {
207+
setMaxNegativeError("Enter a valid non-negative number.");
208+
setMaxNegativeSaving(false);
209+
return;
210+
}
211+
// Store as negative (or zero)
212+
payloadValue = magnitude === 0 ? 0 : -Math.abs(magnitude);
213+
}
214+
215+
await apiClient.patch(`/api/currencies/${currencyId}/max-negative`, {
216+
value: payloadValue,
217+
});
218+
219+
await queryClient.invalidateQueries({ queryKey: ["currency", currencyId] });
220+
await queryClient.invalidateQueries({ queryKey: ["accountDetails", currencyId, accountContext] });
221+
} catch (error: any) {
222+
const message = error?.response?.data?.error || error?.message || "Failed to update max negative balance";
223+
setMaxNegativeError(message);
224+
} finally {
225+
setMaxNegativeSaving(false);
226+
}
227+
};
228+
180229
if (!currencyId) {
181230
return <div>Currency not found</div>;
182231
}
@@ -240,6 +289,16 @@ export default function CurrencyDetail() {
240289
{currency.allowNegative ? "Yes" : "No"}
241290
</p>
242291
</div>
292+
<div>
293+
<h3 className="text-sm font-medium text-muted-foreground mb-1">Max Negative Balance</h3>
294+
<p className="text-lg font-medium">
295+
{currency.allowNegative
296+
? (currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined
297+
? Number(currency.maxNegativeBalance).toLocaleString()
298+
: "No cap")
299+
: "Not applicable"}
300+
</p>
301+
</div>
243302
<div>
244303
<h3 className="text-sm font-medium text-muted-foreground mb-1">Total Currency Supply</h3>
245304
<p className="text-lg font-semibold">
@@ -257,6 +316,66 @@ export default function CurrencyDetail() {
257316
</div>
258317
)}
259318

319+
{/* Max Negative Control - only for admins when negatives are allowed */}
320+
{currency && currency.allowNegative && isAdminOfCurrency && accountContext?.type === "group" && accountContext.id === currency.groupId && (
321+
<div className="bg-white border rounded-lg p-6 mb-6">
322+
<h3 className="text-lg font-semibold mb-2">Set max negative balance</h3>
323+
<p className="text-sm text-muted-foreground mb-4">
324+
Limit how far any account can go negative for this currency. Leave blank for no cap.
325+
</p>
326+
<div className="space-y-4">
327+
<input
328+
type="range"
329+
min={0}
330+
max={MAX_NEGATIVE_SLIDER}
331+
step={0.01}
332+
value={maxNegativeInput === "" ? 0 : Math.min(MAX_NEGATIVE_SLIDER, Math.max(0, Number(maxNegativeInput) || 0))}
333+
onChange={(e) => setMaxNegativeInput(e.target.value)}
334+
className="w-full"
335+
/>
336+
<div className="flex flex-col gap-3 md:flex-row md:items-center">
337+
<div className="flex-1">
338+
<label className="block text-sm font-medium mb-1">Max negative (absolute value)</label>
339+
<input
340+
type="number"
341+
min={0}
342+
max={MAX_NEGATIVE_SLIDER}
343+
step={0.01}
344+
value={maxNegativeInput}
345+
onChange={(e) => setMaxNegativeInput(e.target.value)}
346+
placeholder="Leave blank for no cap"
347+
className="w-full px-4 py-2 border rounded-lg"
348+
/>
349+
<div className="text-xs text-muted-foreground mt-1">
350+
Saved as negative value: {maxNegativeInput === "" ? "No cap" : `-${Math.abs(Number(maxNegativeInput) || 0).toLocaleString()}`}
351+
</div>
352+
</div>
353+
<div className="flex gap-2">
354+
<button
355+
onClick={saveMaxNegative}
356+
disabled={maxNegativeSaving}
357+
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 disabled:opacity-50"
358+
>
359+
{maxNegativeSaving ? "Saving..." : "Save"}
360+
</button>
361+
<button
362+
onClick={() => setMaxNegativeInput("")}
363+
disabled={maxNegativeSaving}
364+
className="px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50"
365+
>
366+
Clear cap
367+
</button>
368+
</div>
369+
</div>
370+
{maxNegativeError && (
371+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg">
372+
{maxNegativeError}
373+
</div>
374+
)}
375+
</div>
376+
</div>
377+
)}
378+
260379
{/* Transactions */}
261380
<div className="mb-6">
262381
<div className="flex justify-between items-center mb-4">

0 commit comments

Comments
 (0)