Skip to content

Commit 5cec93d

Browse files
authored
feat: add burn button and auto reload (#623)
* feat: add burn button and auto reload * feat: add leger tx hashes * feat: make burn work properly * fix: build * chore: migration * chore: fix icon * feat: better TX ui * chore: switch account UX * feat: better table ux * feat: account creation better display
1 parent 7f6e080 commit 5cec93d

File tree

9 files changed

+398
-19
lines changed

9 files changed

+398
-19
lines changed

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import { Request, Response } from "express";
22
import { LedgerService } from "../services/LedgerService";
33
import { AccountType } from "../database/entities/Ledger";
44
import { GroupService } from "../services/GroupService";
5+
import { CurrencyService } from "../services/CurrencyService";
56

67
export class LedgerController {
78
private ledgerService: LedgerService;
89
private groupService: GroupService;
10+
private currencyService: CurrencyService;
911

1012
constructor() {
1113
this.ledgerService = new LedgerService();
1214
this.groupService = new GroupService();
15+
this.currencyService = new CurrencyService();
1316
}
1417

1518
getBalance = async (req: Request, res: Response) => {
@@ -206,6 +209,8 @@ export class LedgerController {
206209
description: entry.description,
207210
balance: entry.balance,
208211
createdAt: entry.createdAt,
212+
hash: entry.hash,
213+
prevHash: entry.prevHash,
209214
sender: sender ? {
210215
name: (sender as any).name || (sender as any).handle,
211216
ename: (sender as any).ename,
@@ -272,6 +277,8 @@ export class LedgerController {
272277
description: transaction.description,
273278
balance: transaction.balance,
274279
createdAt: transaction.createdAt,
280+
hash: transaction.hash,
281+
prevHash: transaction.prevHash,
275282
sender: sender ? {
276283
id: sender.id,
277284
name: (sender as any).name || (sender as any).handle,
@@ -289,6 +296,61 @@ export class LedgerController {
289296
}
290297
};
291298

299+
burn = async (req: Request, res: Response) => {
300+
try {
301+
if (!req.user) {
302+
return res.status(401).json({ error: "Authentication required" });
303+
}
304+
305+
const { currencyId, amount, description } = req.body;
306+
307+
if (!currencyId || amount === undefined || amount === null) {
308+
return res.status(400).json({ error: "currencyId and amount are required" });
309+
}
310+
311+
const parsedAmount = Number(amount);
312+
if (Number.isNaN(parsedAmount) || parsedAmount <= 0) {
313+
return res.status(400).json({ error: "Amount must be a positive number" });
314+
}
315+
316+
const currency = await this.currencyService.getCurrencyById(currencyId);
317+
if (!currency) {
318+
return res.status(404).json({ error: "Currency not found" });
319+
}
320+
321+
// Only group admins of the currency's group can burn (treasury)
322+
const isAdmin = await this.groupService.isGroupAdmin(currency.groupId, req.user.id);
323+
if (!isAdmin) {
324+
return res.status(403).json({ error: "Only group admins can burn from treasury" });
325+
}
326+
327+
const burnEntry = await this.ledgerService.burn(
328+
currencyId,
329+
currency.groupId,
330+
parsedAmount,
331+
description
332+
);
333+
334+
res.status(200).json({
335+
message: "Burn successful",
336+
transaction: {
337+
id: burnEntry.id,
338+
amount: burnEntry.amount,
339+
balance: burnEntry.balance,
340+
createdAt: burnEntry.createdAt,
341+
hash: burnEntry.hash,
342+
prevHash: burnEntry.prevHash,
343+
},
344+
});
345+
} catch (error: any) {
346+
console.error("Error burning currency:", error);
347+
if (error.message.includes("Insufficient balance") || error.message.includes("not found")) {
348+
return res.status(400).json({ error: error.message });
349+
}
350+
res.status(500).json({ error: "Internal server error" });
351+
}
352+
};
353+
292354
initializeAccount = async (req: Request, res: Response) => {
293355
try {
294356
if (!req.user) {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export class Ledger {
7676
@Column("decimal", { precision: 18, scale: 2 })
7777
balance!: number; // Running balance after this entry
7878

79+
@Column({ type: "text", nullable: true })
80+
hash!: string | null;
81+
82+
@Column({ type: "text", nullable: true })
83+
prevHash!: string | null;
84+
7985
@CreateDateColumn()
8086
createdAt!: Date;
8187
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class Migration1765809758435 implements MigrationInterface {
4+
name = 'Migration1765809758435'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "ledger" ADD "hash" text`);
8+
await queryRunner.query(`ALTER TABLE "ledger" ADD "prevHash" text`);
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "prevHash"`);
13+
await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "hash"`);
14+
}
15+
16+
}

platforms/eCurrency-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ app.patch("/api/currencies/:id/max-negative", authGuard, currencyController.upda
118118
app.get("/api/ledger/balance", authGuard, ledgerController.getBalance);
119119
app.get("/api/ledger/balance/:currencyId", authGuard, ledgerController.getBalanceByCurrencyId);
120120
app.post("/api/ledger/transfer", authGuard, ledgerController.transfer);
121+
app.post("/api/ledger/burn", authGuard, ledgerController.burn);
121122
app.get("/api/ledger/history", authGuard, ledgerController.getHistory);
122123
app.get("/api/ledger/history/:currencyId", authGuard, ledgerController.getHistory);
123124
app.get("/api/ledger/transaction/:id", authGuard, ledgerController.getTransactionById);

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

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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 crypto from "crypto";
89

910
export class LedgerService {
1011
ledgerRepository: Repository<Ledger>;
@@ -15,6 +16,18 @@ export class LedgerService {
1516
this.currencyRepository = AppDataSource.getRepository(Currency);
1617
}
1718

19+
private computeHash(payload: any): string {
20+
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
21+
}
22+
23+
private async getPrevHash(currencyId: string): Promise<string | null> {
24+
const prev = await this.ledgerRepository.findOne({
25+
where: { currencyId },
26+
order: { createdAt: "DESC", id: "DESC" },
27+
});
28+
return prev?.hash ?? null;
29+
}
30+
1831
async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise<number> {
1932
const latestEntry = await this.ledgerRepository.findOne({
2033
where: {
@@ -67,10 +80,34 @@ export class LedgerService {
6780
balance: newBalance,
6881
};
6982

70-
const entry = this.ledgerRepository.create(entryData);
83+
// capture previous hash before inserting current entry
84+
const prevHash = await this.getPrevHash(currencyId);
7185

86+
const entry = this.ledgerRepository.create(entryData);
7287
const saved = await this.ledgerRepository.save(entry);
73-
return Array.isArray(saved) ? saved[0] : saved;
88+
89+
const hashPayload = {
90+
id: saved.id,
91+
currencyId: saved.currencyId,
92+
accountId: saved.accountId,
93+
accountType: saved.accountType,
94+
amount: saved.amount,
95+
type: saved.type,
96+
description: saved.description,
97+
senderAccountId: saved.senderAccountId,
98+
senderAccountType: saved.senderAccountType,
99+
receiverAccountId: saved.receiverAccountId,
100+
receiverAccountType: saved.receiverAccountType,
101+
balance: saved.balance,
102+
createdAt: saved.createdAt,
103+
prevHash,
104+
};
105+
106+
saved.prevHash = prevHash;
107+
saved.hash = this.computeHash(hashPayload);
108+
109+
const finalized = await this.ledgerRepository.save(saved);
110+
return Array.isArray(finalized) ? finalized[0] : finalized;
74111
}
75112

76113
async transfer(
@@ -160,6 +197,54 @@ export class LedgerService {
160197
return { debit, credit };
161198
}
162199

200+
async burn(
201+
currencyId: string,
202+
groupId: string,
203+
amount: number,
204+
description?: string
205+
): Promise<Ledger> {
206+
const currency = await this.currencyRepository.findOne({ where: { id: currencyId } });
207+
if (!currency) {
208+
throw new Error("Currency not found");
209+
}
210+
211+
// Ensure treasury account exists
212+
await this.initializeAccount(currencyId, groupId, AccountType.GROUP);
213+
214+
const currentBalance = await this.getAccountBalance(currencyId, groupId, AccountType.GROUP);
215+
216+
// Enforce bounds
217+
if (!currency.allowNegative && currentBalance < amount) {
218+
throw new Error("Insufficient balance. Negative balances are not allowed.");
219+
}
220+
221+
if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) {
222+
const newBalance = currentBalance - amount;
223+
if (newBalance < Number(currency.maxNegativeBalance)) {
224+
throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`);
225+
}
226+
}
227+
228+
const burnDescription = description || `Burned ${amount} ${currency.name}`;
229+
230+
// Single debit entry against treasury
231+
const debit = await this.addLedgerEntry(
232+
currencyId,
233+
groupId,
234+
AccountType.GROUP,
235+
amount,
236+
LedgerType.DEBIT,
237+
burnDescription,
238+
groupId,
239+
AccountType.GROUP,
240+
undefined,
241+
undefined,
242+
currentBalance
243+
);
244+
245+
return debit;
246+
}
247+
163248
async getTransactionHistory(
164249
currencyId?: string,
165250
accountId?: string,

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,13 @@ export default function AddCurrencyAccountModal({ open, onOpenChange, accountCon
7272
return response.data;
7373
},
7474
onSuccess: () => {
75-
queryClient.invalidateQueries({ queryKey: balancesQueryKey });
75+
// Refresh balances and history for the active context right after adding
76+
queryClient.invalidateQueries({
77+
predicate: (q) => {
78+
const key = q.queryKey?.[0];
79+
return key === "balances" || key === "history" || key === "accountDetails";
80+
},
81+
});
7682
setCurrencyId("");
7783
setSearchQuery("");
7884
setIsOpen(false);

platforms/eCurrency/client/src/components/currency/transaction-card.tsx

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
1+
import { ArrowLeft, ArrowRight, Sparkles, Flame } from "lucide-react";
22
import { formatEName } from "../../lib/utils";
33

44
interface TransactionCardProps {
@@ -9,10 +9,43 @@ interface TransactionCardProps {
99

1010
export default function TransactionCard({ transaction, currencyName, onClick }: TransactionCardProps) {
1111
// For credits: show "Received from X", for debits: show "Sent to X"
12-
// For minted: show "Minted"
12+
// For minted: show "Minted", for burned: show "Burned"
13+
// For account initialization: show "Account Created"
1314
let mainText = "";
14-
if (transaction.description?.includes("Minted")) {
15+
const desc = transaction.description?.toLowerCase() || "";
16+
const balance = Number(transaction.balance) || 0;
17+
const amount = Math.abs(Number(transaction.amount)) || 0;
18+
19+
// Check description for mint/burn keywords
20+
const hasMintInDesc = desc.includes("minted") || desc.includes("mint");
21+
const hasBurnInDesc = desc.includes("burned") || desc.includes("burn");
22+
const hasInitInDesc = desc.includes("initialized") || desc.includes("account created");
23+
24+
// Account initialization: balance is 0, amount is 0 (or very close), no sender
25+
const isAccountInit = balance === 0 && amount < 0.01 &&
26+
(!transaction.sender || (!transaction.sender.name && !transaction.sender.ename)) &&
27+
(hasInitInDesc || transaction.description === "Account initialized");
28+
29+
// Fallback: credit with no sender and group account type is likely a mint (but not if balance is 0)
30+
const isLikelyMint = transaction.type === "credit" &&
31+
(!transaction.sender || (!transaction.sender.name && !transaction.sender.ename)) &&
32+
transaction.accountType === "group" &&
33+
balance > 0; // Only consider it a mint if balance is positive
34+
35+
// Debit with no receiver and group account type is likely a burn
36+
const isLikelyBurn = transaction.type === "debit" &&
37+
(!transaction.receiver || (!transaction.receiver.name && !transaction.receiver.ename)) &&
38+
transaction.accountType === "group";
39+
40+
const isMinted = hasMintInDesc || isLikelyMint;
41+
const isBurned = hasBurnInDesc || isLikelyBurn;
42+
43+
if (isAccountInit) {
44+
mainText = "Account Created";
45+
} else if (isMinted) {
1546
mainText = "Minted";
47+
} else if (isBurned) {
48+
mainText = "Burned";
1649
} else if (transaction.type === "credit") {
1750
const senderName = transaction.sender?.name || formatEName(transaction.sender?.ename) || "Unknown";
1851
mainText = `Received from ${senderName}`;
@@ -21,8 +54,6 @@ export default function TransactionCard({ transaction, currencyName, onClick }:
2154
mainText = `Sent to ${receiverName}`;
2255
}
2356

24-
const isMinted = transaction.description?.includes("Minted");
25-
2657
return (
2758
<div
2859
className="p-4 hover:bg-gray-50 transition-colors cursor-pointer"
@@ -34,13 +65,17 @@ export default function TransactionCard({ transaction, currencyName, onClick }:
3465
className={`w-10 h-10 rounded-full flex items-center justify-center ${
3566
isMinted
3667
? "bg-purple-100"
37-
: transaction.type === "credit"
38-
? "bg-green-100"
39-
: "bg-blue-100"
68+
: isBurned
69+
? "bg-red-100"
70+
: transaction.type === "credit"
71+
? "bg-green-100"
72+
: "bg-blue-100"
4073
}`}
4174
>
4275
{isMinted ? (
4376
<Sparkles className="h-5 w-5 text-purple-600" />
77+
) : isBurned ? (
78+
<Flame className="h-5 w-5 text-red-600" />
4479
) : transaction.type === "credit" ? (
4580
<ArrowLeft className="h-5 w-5 text-green-600" />
4681
) : (

0 commit comments

Comments
 (0)