Skip to content

Commit 31da4db

Browse files
authored
Feat/ecurrency overdraft toggle (#819)
* fix: correct envDir path in Vite configuration * feat: add allowNegativeGroupOnly feature to currency management * feat: implement overdraft validation for group members and enhance negative balance checks
1 parent fe075e5 commit 31da4db

11 files changed

Lines changed: 111 additions & 26 deletions

File tree

platforms/ecurrency/api/src/controllers/CurrencyController.ts

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

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

2020
if (!name || !groupId) {
2121
return res.status(400).json({ error: "Name and groupId are required" });
2222
}
2323

2424
const allowNegativeFlag = Boolean(allowNegative);
25+
const allowNegativeGroupOnlyFlag = Boolean(allowNegativeGroupOnly);
2526
let normalizedMaxNegative: number | null = null;
2627

2728
if (maxNegativeBalance !== undefined && maxNegativeBalance !== null && maxNegativeBalance !== "") {
@@ -48,7 +49,8 @@ export class CurrencyController {
4849
req.user.id,
4950
allowNegativeFlag,
5051
normalizedMaxNegative,
51-
description
52+
description,
53+
allowNegativeGroupOnlyFlag
5254
);
5355

5456
res.status(201).json({
@@ -58,6 +60,7 @@ export class CurrencyController {
5860
ename: currency.ename,
5961
groupId: currency.groupId,
6062
allowNegative: currency.allowNegative,
63+
allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
6164
maxNegativeBalance: currency.maxNegativeBalance,
6265
createdBy: currency.createdBy,
6366
createdAt: currency.createdAt,
@@ -81,6 +84,7 @@ export class CurrencyController {
8184
ename: currency.ename,
8285
groupId: currency.groupId,
8386
allowNegative: currency.allowNegative,
87+
allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
8488
maxNegativeBalance: currency.maxNegativeBalance,
8589
createdBy: currency.createdBy,
8690
createdAt: currency.createdAt,
@@ -108,6 +112,7 @@ export class CurrencyController {
108112
ename: currency.ename,
109113
groupId: currency.groupId,
110114
allowNegative: currency.allowNegative,
115+
allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
111116
maxNegativeBalance: currency.maxNegativeBalance,
112117
createdBy: currency.createdBy,
113118
createdAt: currency.createdAt,
@@ -130,6 +135,7 @@ export class CurrencyController {
130135
ename: currency.ename,
131136
groupId: currency.groupId,
132137
allowNegative: currency.allowNegative,
138+
allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
133139
maxNegativeBalance: currency.maxNegativeBalance,
134140
createdBy: currency.createdBy,
135141
createdAt: currency.createdAt,

platforms/ecurrency/api/src/controllers/LedgerController.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class LedgerController {
5151
name: b.currency.name,
5252
ename: b.currency.ename,
5353
allowNegative: b.currency.allowNegative,
54+
allowNegativeGroupOnly: b.currency.allowNegativeGroupOnly,
5455
},
5556
balance: b.balance,
5657
})));

platforms/ecurrency/api/src/database/entities/Currency.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export class Currency {
3939
@Column("decimal", { precision: 18, scale: 2, nullable: true })
4040
maxNegativeBalance!: number | null;
4141

42+
@Column({ default: false })
43+
allowNegativeGroupOnly!: boolean;
44+
4245
@Column()
4346
createdBy!: string;
4447

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class AddAllowNegativeGroupOnly1771523000000 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query(`ALTER TABLE "currencies" ADD "allowNegativeGroupOnly" boolean NOT NULL DEFAULT false`);
6+
}
7+
8+
public async down(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(`ALTER TABLE "currencies" DROP COLUMN "allowNegativeGroupOnly"`);
10+
}
11+
}

platforms/ecurrency/api/src/services/CurrencyService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export class CurrencyService {
2323
createdBy: string,
2424
allowNegative: boolean = false,
2525
maxNegativeBalance: number | null = null,
26-
description?: string
26+
description?: string,
27+
allowNegativeGroupOnly: boolean = false
2728
): Promise<Currency> {
2829
// Verify user is group admin
2930
const isAdmin = await this.groupService.isGroupAdmin(groupId, createdBy);
@@ -46,6 +47,10 @@ export class CurrencyService {
4647
}
4748
}
4849

50+
if (allowNegativeGroupOnly && !allowNegative) {
51+
throw new Error("Cannot restrict overdraft to group members when negative balances are disabled");
52+
}
53+
4954
const currency = this.currencyRepository.create({
5055
name,
5156
description,
@@ -54,6 +59,7 @@ export class CurrencyService {
5459
createdBy,
5560
allowNegative,
5661
maxNegativeBalance,
62+
allowNegativeGroupOnly,
5763
});
5864

5965
const savedCurrency = await this.currencyRepository.save(currency);

platforms/ecurrency/api/src/services/GroupService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,5 +234,19 @@ export class GroupService {
234234
if (!group) return false;
235235
return group.admins.some(admin => admin.id === userId);
236236
}
237+
238+
async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
239+
const group = await this.groupRepository.findOne({
240+
where: { id: groupId },
241+
relations: ["members", "participants", "admins"]
242+
});
243+
if (!group) return false;
244+
245+
// Check if user is the owner, a member, participant, or admin
246+
return group.owner === userId ||
247+
group.members.some(m => m.id === userId) ||
248+
group.participants.some(p => p.id === userId) ||
249+
group.admins.some(a => a.id === userId);
250+
}
237251
}
238252

platforms/ecurrency/api/src/services/LedgerService.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import { Currency } from "../database/entities/Currency";
55
import { User } from "../database/entities/User";
66
import { Group } from "../database/entities/Group";
77
import { TransactionNotificationService } from "./TransactionNotificationService";
8+
import { GroupService } from "./GroupService";
89
import crypto from "crypto";
910

1011
export class LedgerService {
1112
ledgerRepository: Repository<Ledger>;
1213
currencyRepository: Repository<Currency>;
14+
groupService: GroupService;
1315

1416
constructor() {
1517
this.ledgerRepository = AppDataSource.getRepository(Ledger);
1618
this.currencyRepository = AppDataSource.getRepository(Currency);
19+
this.groupService = new GroupService();
1720
}
1821

1922
private computeHash(payload: any): string {
@@ -28,6 +31,23 @@ export class LedgerService {
2831
return prev?.hash ?? null;
2932
}
3033

34+
/**
35+
* Validates whether a debit of `amount` from `currentBalance` is allowed
36+
* given the currency's negative-balance settings.
37+
*/
38+
private validateNegativeAllowance(currency: Currency, currentBalance: number, amount: number): void {
39+
if (!currency.allowNegative && currentBalance < amount) {
40+
throw new Error("Insufficient balance. This currency does not allow negative balances.");
41+
}
42+
43+
if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) {
44+
const newBalance = currentBalance - amount;
45+
if (newBalance < Number(currency.maxNegativeBalance)) {
46+
throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`);
47+
}
48+
}
49+
}
50+
3151
async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise<number> {
3252
const latestEntry = await this.ledgerRepository.findOne({
3353
where: {
@@ -132,16 +152,23 @@ export class LedgerService {
132152

133153
const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType);
134154

135-
// Validate debit bounds
136-
if (!currency.allowNegative && currentBalance < amount) {
137-
throw new Error("Insufficient balance. This currency does not allow negative balances.");
138-
}
139-
140-
if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) {
141-
const newBalance = currentBalance - amount;
142-
if (newBalance < Number(currency.maxNegativeBalance)) {
143-
throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`);
155+
// Validate debit bounds.
156+
// When allowNegativeGroupOnly is true, only group members may overdraft;
157+
// non-members are limited to their current balance. Note: the invariant
158+
// allowNegativeGroupOnly ⇒ allowNegative is enforced at creation time
159+
// in CurrencyService.createCurrency.
160+
if (currency.allowNegativeGroupOnly && fromAccountType === AccountType.USER) {
161+
const isMember = await this.groupService.isUserInGroup(currency.groupId, fromAccountId);
162+
163+
if (!isMember) {
164+
if (currentBalance < amount) {
165+
throw new Error("Insufficient balance. Only group members can have negative balances for this currency.");
166+
}
167+
} else {
168+
this.validateNegativeAllowance(currency, currentBalance, amount);
144169
}
170+
} else {
171+
this.validateNegativeAllowance(currency, currentBalance, amount);
145172
}
146173

147174
// Create debit entry (from sender's account)

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
1616
const [description, setDescription] = useState("");
1717
const [groupId, setGroupId] = useState("");
1818
const [allowNegative, setAllowNegative] = useState(false);
19+
const [allowNegativeGroupOnly, setAllowNegativeGroupOnly] = useState(false);
1920
const [maxNegativeInput, setMaxNegativeInput] = useState("");
2021
const [error, setError] = useState<string | null>(null);
2122
const queryClient = useQueryClient();
@@ -28,6 +29,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
2829
description?: string;
2930
groupId: string;
3031
allowNegative: boolean;
32+
allowNegativeGroupOnly: boolean;
3133
maxNegativeBalance: number | null;
3234
}) => {
3335
const response = await apiClient.post("/api/currencies", data);
@@ -40,6 +42,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
4042
setDescription("");
4143
setGroupId("");
4244
setAllowNegative(false);
45+
setAllowNegativeGroupOnly(false);
4346
setMaxNegativeInput("");
4447
setError(null);
4548
onOpenChange(false);
@@ -91,6 +94,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
9194
description,
9295
groupId,
9396
allowNegative,
97+
allowNegativeGroupOnly,
9498
maxNegativeBalance: maxNegativeValue,
9599
});
96100
}}
@@ -142,6 +146,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
142146
type="button"
143147
onClick={() => {
144148
setAllowNegative(false);
149+
setAllowNegativeGroupOnly(false);
145150
setMaxNegativeInput("");
146151
}}
147152
className={`p-4 border-2 rounded-lg text-left transition-all ${
@@ -173,8 +178,24 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
173178
</div>
174179

175180
{allowNegative && (
176-
<div>
177-
<label className="block text-sm font-medium mb-1">Max negative balance (absolute value)</label>
181+
<>
182+
<div>
183+
<label className="flex items-center gap-2 cursor-pointer">
184+
<input
185+
type="checkbox"
186+
checked={allowNegativeGroupOnly}
187+
onChange={(e) => setAllowNegativeGroupOnly(e.target.checked)}
188+
className="w-4 h-4 rounded border-gray-300"
189+
/>
190+
<span className="text-sm font-medium">Restrict overdraft to group members only</span>
191+
</label>
192+
<p className="text-xs text-muted-foreground mt-1 ml-6">
193+
When enabled, only users who are members of the currency's group can have negative balances. Non-members must maintain a positive balance.
194+
</p>
195+
</div>
196+
197+
<div>
198+
<label className="block text-sm font-medium mb-1">Max negative balance (absolute value)</label>
178199
<input
179200
type="number"
180201
min={0}
@@ -189,6 +210,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
189210
Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}).
190211
</p>
191212
</div>
213+
</>
192214
)}
193215

194216
{error && (

platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,9 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco
244244
)}
245245
{fromCurrencyData && fromCurrencyData.allowNegative && (
246246
<p className="text-sm text-muted-foreground mt-1">
247-
Negative balances are allowed for this currency
247+
{fromCurrencyData.allowNegativeGroupOnly
248+
? "Negative balances allowed for group members"
249+
: "Negative balances are allowed for this currency"}
248250
</p>
249251
)}
250252
</div>
@@ -353,15 +355,6 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco
353355
</div>
354356
)}
355357

356-
{/* Mutation Error */}
357-
{transferMutation.isError && (
358-
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
359-
{transferMutation.error instanceof Error
360-
? transferMutation.error.message
361-
: "An error occurred while processing the transfer"}
362-
</div>
363-
)}
364-
365358
<div className="flex gap-2 justify-end pt-4">
366359
<button
367360
type="button"

platforms/ecurrency/client/client/src/pages/dashboard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ export default function Dashboard() {
224224
<div className="text-xs text-white/80 mt-1">{formatEName(balance.currency.ename)}</div>
225225
{balance.currency.allowNegative !== undefined && (
226226
<div className="text-xs px-2 py-0.5 rounded bg-white/20 text-white/90 mt-1 inline-block">
227-
{balance.currency.allowNegative ? "Negative Allowed" : "No Negative"}
227+
{balance.currency.allowNegative
228+
? (balance.currency.allowNegativeGroupOnly ? "Negative Allowed (Members only)" : "Negative Allowed")
229+
: "No Negative"}
228230
</div>
229231
)}
230232
</div>

0 commit comments

Comments
 (0)