From 28a6b7b2621046f7c5800ef0c92de04268247153 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Mon, 15 Dec 2025 14:14:36 +0530 Subject: [PATCH] feat: Add max negative caps and enforce ledger bounds --- .../src/controllers/CurrencyController.ts | 49 ++++++- .../src/database/entities/Currency.ts | 3 + .../migrations/1765784749012-migration.ts | 14 ++ platforms/eCurrency-api/src/index.ts | 1 + .../src/services/CurrencyService.ts | 34 +++++ .../src/services/LedgerService.ts | 26 ++-- .../client/src/pages/currency-detail.tsx | 121 +++++++++++++++++- 7 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 platforms/eCurrency-api/src/database/migrations/1765784749012-migration.ts diff --git a/platforms/eCurrency-api/src/controllers/CurrencyController.ts b/platforms/eCurrency-api/src/controllers/CurrencyController.ts index 5b33c102..61b9f1b8 100644 --- a/platforms/eCurrency-api/src/controllers/CurrencyController.ts +++ b/platforms/eCurrency-api/src/controllers/CurrencyController.ts @@ -15,7 +15,7 @@ export class CurrencyController { return res.status(401).json({ error: "Authentication required" }); } - const { name, description, groupId, allowNegative } = req.body; + const { name, description, groupId, allowNegative, maxNegativeBalance } = req.body; if (!name || !groupId) { return res.status(400).json({ error: "Name and groupId are required" }); @@ -26,6 +26,7 @@ export class CurrencyController { groupId, req.user.id, allowNegative || false, + maxNegativeBalance ?? null, description ); @@ -36,6 +37,7 @@ export class CurrencyController { ename: currency.ename, groupId: currency.groupId, allowNegative: currency.allowNegative, + maxNegativeBalance: currency.maxNegativeBalance, createdBy: currency.createdBy, createdAt: currency.createdAt, updatedAt: currency.updatedAt, @@ -58,6 +60,7 @@ export class CurrencyController { ename: currency.ename, groupId: currency.groupId, allowNegative: currency.allowNegative, + maxNegativeBalance: currency.maxNegativeBalance, createdBy: currency.createdBy, createdAt: currency.createdAt, updatedAt: currency.updatedAt, @@ -84,6 +87,7 @@ export class CurrencyController { ename: currency.ename, groupId: currency.groupId, allowNegative: currency.allowNegative, + maxNegativeBalance: currency.maxNegativeBalance, createdBy: currency.createdBy, createdAt: currency.createdAt, updatedAt: currency.updatedAt, @@ -105,6 +109,7 @@ export class CurrencyController { ename: currency.ename, groupId: currency.groupId, allowNegative: currency.allowNegative, + maxNegativeBalance: currency.maxNegativeBalance, createdBy: currency.createdBy, createdAt: currency.createdAt, updatedAt: currency.updatedAt, @@ -144,5 +149,47 @@ export class CurrencyController { res.status(500).json({ error: "Internal server error" }); } }; + + updateMaxNegativeBalance = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { value } = req.body; + + // Allow null to clear; otherwise must be a number + const parsedValue = value === null || value === undefined ? null : Number(value); + if (parsedValue !== null && Number.isNaN(parsedValue)) { + return res.status(400).json({ error: "Invalid value for maxNegativeBalance" }); + } + + const updated = await this.currencyService.updateMaxNegativeBalance( + id, + parsedValue, + req.user.id + ); + + res.status(200).json({ + id: updated.id, + name: updated.name, + description: updated.description, + ename: updated.ename, + groupId: updated.groupId, + allowNegative: updated.allowNegative, + maxNegativeBalance: updated.maxNegativeBalance, + createdBy: updated.createdBy, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }); + } catch (error: any) { + console.error("Error updating max negative balance:", error); + if (error.message.includes("Only group admins") || error.message.includes("not found") || error.message.includes("Cannot set max negative") || error.message.includes("Max negative")) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Internal server error" }); + } + }; } diff --git a/platforms/eCurrency-api/src/database/entities/Currency.ts b/platforms/eCurrency-api/src/database/entities/Currency.ts index 385f786a..20e015d1 100644 --- a/platforms/eCurrency-api/src/database/entities/Currency.ts +++ b/platforms/eCurrency-api/src/database/entities/Currency.ts @@ -36,6 +36,9 @@ export class Currency { @Column({ default: false }) allowNegative!: boolean; + @Column("decimal", { precision: 18, scale: 2, nullable: true }) + maxNegativeBalance!: number | null; + @Column() createdBy!: string; diff --git a/platforms/eCurrency-api/src/database/migrations/1765784749012-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765784749012-migration.ts new file mode 100644 index 00000000..e577c144 --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765784749012-migration.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765784749012 implements MigrationInterface { + name = 'Migration1765784749012' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "currencies" ADD "maxNegativeBalance" numeric(18,2)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "currencies" DROP COLUMN "maxNegativeBalance"`); + } + +} diff --git a/platforms/eCurrency-api/src/index.ts b/platforms/eCurrency-api/src/index.ts index 3ec4247c..5a052bdc 100644 --- a/platforms/eCurrency-api/src/index.ts +++ b/platforms/eCurrency-api/src/index.ts @@ -112,6 +112,7 @@ app.get("/api/currencies", currencyController.getAllCurrencies); app.get("/api/currencies/:id", currencyController.getCurrencyById); app.get("/api/currencies/group/:groupId", currencyController.getCurrenciesByGroup); app.post("/api/currencies/:id/mint", authGuard, currencyController.mintCurrency); +app.patch("/api/currencies/:id/max-negative", authGuard, currencyController.updateMaxNegativeBalance); // Ledger routes app.get("/api/ledger/balance", authGuard, ledgerController.getBalance); diff --git a/platforms/eCurrency-api/src/services/CurrencyService.ts b/platforms/eCurrency-api/src/services/CurrencyService.ts index afcb931e..650d9969 100644 --- a/platforms/eCurrency-api/src/services/CurrencyService.ts +++ b/platforms/eCurrency-api/src/services/CurrencyService.ts @@ -22,6 +22,7 @@ export class CurrencyService { groupId: string, createdBy: string, allowNegative: boolean = false, + maxNegativeBalance: number | null = null, description?: string ): Promise { // Verify user is group admin @@ -40,6 +41,7 @@ export class CurrencyService { groupId, createdBy, allowNegative, + maxNegativeBalance, }); const savedCurrency = await this.currencyRepository.save(currency); @@ -82,6 +84,38 @@ export class CurrencyService { }); } + async updateMaxNegativeBalance( + currencyId: string, + value: number | null, + requestedBy: string + ): Promise { + const currency = await this.getCurrencyById(currencyId); + if (!currency) { + throw new Error("Currency not found"); + } + + const isAdmin = await this.groupService.isGroupAdmin(currency.groupId, requestedBy); + if (!isAdmin) { + throw new Error("Only group admins can update max negative balance"); + } + + if (!currency.allowNegative) { + throw new Error("Cannot set max negative balance when negative balances are not allowed"); + } + + if (value !== null) { + if (Number.isNaN(value)) { + throw new Error("Invalid max negative balance value"); + } + if (value > 0) { + throw new Error("Max negative balance must be zero or negative"); + } + } + + currency.maxNegativeBalance = value; + return await this.currencyRepository.save(currency); + } + async mintCurrency( currencyId: string, amount: number, diff --git a/platforms/eCurrency-api/src/services/LedgerService.ts b/platforms/eCurrency-api/src/services/LedgerService.ts index 6cbae4f3..0f0b6422 100644 --- a/platforms/eCurrency-api/src/services/LedgerService.ts +++ b/platforms/eCurrency-api/src/services/LedgerService.ts @@ -40,10 +40,13 @@ export class LedgerService { senderAccountId?: string, senderAccountType?: AccountType, receiverAccountId?: string, - receiverAccountType?: AccountType + receiverAccountType?: AccountType, + existingBalance?: number ): Promise { // Get current balance - const currentBalance = await this.getAccountBalance(currencyId, accountId, accountType); + const currentBalance = existingBalance !== undefined + ? existingBalance + : await this.getAccountBalance(currencyId, accountId, accountType); // Calculate new balance const newBalance = type === LedgerType.CREDIT @@ -90,11 +93,17 @@ export class LedgerService { throw new Error("Currency not found"); } - // Only check balance if negative balances are not allowed - if (!currency.allowNegative) { - const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType); - if (currentBalance < amount) { - throw new Error("Insufficient balance. This currency does not allow negative balances."); + const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType); + + // Validate debit bounds + if (!currency.allowNegative && currentBalance < amount) { + throw new Error("Insufficient balance. This currency does not allow negative balances."); + } + + if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) { + const newBalance = currentBalance - amount; + if (newBalance < Number(currency.maxNegativeBalance)) { + throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`); } } @@ -110,7 +119,8 @@ export class LedgerService { fromAccountId, // sender fromAccountType, // sender type toAccountId, // receiver - toAccountType // receiver type + toAccountType, // receiver type + currentBalance ); // Create credit entry (to receiver's account) diff --git a/platforms/eCurrency/client/src/pages/currency-detail.tsx b/platforms/eCurrency/client/src/pages/currency-detail.tsx index 01f61523..1a5c149f 100644 --- a/platforms/eCurrency/client/src/pages/currency-detail.tsx +++ b/platforms/eCurrency/client/src/pages/currency-detail.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useRoute } from "wouter"; import { apiClient } from "../lib/apiClient"; import { useAuth } from "../hooks/useAuth"; @@ -15,12 +15,17 @@ export default function CurrencyDetail() { const [, params] = useRoute("/currency/:currencyId"); const [, setLocation] = useLocation(); const { user } = useAuth(); + const queryClient = useQueryClient(); const [transferOpen, setTransferOpen] = useState(false); const [mintOpen, setMintOpen] = useState(false); const [selectedTransactionId, setSelectedTransactionId] = useState(null); const [transactionOffset, setTransactionOffset] = useState(0); const [allTransactions, setAllTransactions] = useState([]); const PAGE_SIZE = 10; + const MAX_NEGATIVE_SLIDER = 1_000_000; + const [maxNegativeInput, setMaxNegativeInput] = useState(""); + const [maxNegativeSaving, setMaxNegativeSaving] = useState(false); + const [maxNegativeError, setMaxNegativeError] = useState(null); // Load account context from localStorage const [accountContext, setAccountContext] = useState<{ type: "user" | "group"; id: string } | null>(() => { @@ -48,6 +53,16 @@ export default function CurrencyDetail() { enabled: !!currencyId, }); + useEffect(() => { + if (currency) { + if (currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) { + setMaxNegativeInput(Math.abs(Number(currency.maxNegativeBalance)).toString()); + } else { + setMaxNegativeInput(""); + } + } + }, [currency]); + const { data: accountDetails } = useQuery({ queryKey: ["accountDetails", currencyId, accountContext], queryFn: async () => { @@ -177,6 +192,40 @@ export default function CurrencyDetail() { const isAdminOfCurrency = currency && groups?.some((g: any) => g.id === currency.groupId && g.isAdmin); + const saveMaxNegative = async () => { + if (!currencyId) return; + setMaxNegativeError(null); + setMaxNegativeSaving(true); + try { + const trimmed = maxNegativeInput.trim(); + const isClearing = trimmed === ""; + let payloadValue: number | null = null; + + if (!isClearing) { + const magnitude = parseFloat(trimmed); + if (Number.isNaN(magnitude) || magnitude < 0) { + setMaxNegativeError("Enter a valid non-negative number."); + setMaxNegativeSaving(false); + return; + } + // Store as negative (or zero) + payloadValue = magnitude === 0 ? 0 : -Math.abs(magnitude); + } + + await apiClient.patch(`/api/currencies/${currencyId}/max-negative`, { + value: payloadValue, + }); + + await queryClient.invalidateQueries({ queryKey: ["currency", currencyId] }); + await queryClient.invalidateQueries({ queryKey: ["accountDetails", currencyId, accountContext] }); + } catch (error: any) { + const message = error?.response?.data?.error || error?.message || "Failed to update max negative balance"; + setMaxNegativeError(message); + } finally { + setMaxNegativeSaving(false); + } + }; + if (!currencyId) { return
Currency not found
; } @@ -240,6 +289,16 @@ export default function CurrencyDetail() { {currency.allowNegative ? "Yes" : "No"}

+
+

Max Negative Balance

+

+ {currency.allowNegative + ? (currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined + ? Number(currency.maxNegativeBalance).toLocaleString() + : "No cap") + : "Not applicable"} +

+

Total Currency Supply

@@ -257,6 +316,66 @@ export default function CurrencyDetail() {

)} + {/* Max Negative Control - only for admins when negatives are allowed */} + {currency && currency.allowNegative && isAdminOfCurrency && accountContext?.type === "group" && accountContext.id === currency.groupId && ( +
+

Set max negative balance

+

+ Limit how far any account can go negative for this currency. Leave blank for no cap. +

+
+ setMaxNegativeInput(e.target.value)} + className="w-full" + /> +
+
+ + setMaxNegativeInput(e.target.value)} + placeholder="Leave blank for no cap" + className="w-full px-4 py-2 border rounded-lg" + /> +
+ Saved as negative value: {maxNegativeInput === "" ? "No cap" : `-${Math.abs(Number(maxNegativeInput) || 0).toLocaleString()}`} +
+
+
+ + +
+
+ {maxNegativeError && ( +
+ {maxNegativeError} +
+ )} +
+
+ )} + {/* Transactions */}