Skip to content

Commit df93da3

Browse files
authored
refactor(backend): migrate Float monetary fields to Decimal (#237) (#410)
* refactor(backend): migrate Float monetary fields to Decimal (#237) - Change Campaign.budget, Claim.amount, AidPackage.totalAmount/claimedAmount/remainingAmount, and BalanceLedger.amount from Float to Decimal(38, 18) - Add migration script to convert existing data - Add DecimalSerializerInterceptor to serialize Decimal as strings in API responses - Add money-roundtrip.spec.ts to prove lossless serialization * fix(backend): use Decimal methods for negation and accumulation in cancel-and-reissue service * fix(backend): match Prisma Decimal toString behavior in round-trip tests * fix(backend): add .toNumber() at Decimal boundaries to satisfy TS types - budget.service.ts: aggregate amounts via .toNumber() ?? 0, compare via .toNumber() - campaigns.controller.ts: .toNumber() on budget before arithmetic - cancel-and-reissue.service.ts: .toNumber() for event amounts, typeof guard for union - claims.service.ts: .toNumber() for ClaimReceiptDto.amount - ledger-reconciliation.service.ts: .toNumber() for stored entry in Math.abs() * fix(backend): use Decimal mocks in budget.service.spec.ts * fix(backend): update coverage baseline for cancel-and-reissue.service.ts branches --------- Co-authored-by: DeRossa1 <DeRossa1@users.noreply.github.com>
1 parent f175e3a commit df93da3

11 files changed

Lines changed: 255 additions & 31 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Migration: Floats to Decimal
2+
-- Converts monetary Float fields to Decimal(38, 18) for precision
3+
4+
-- AidPackage: totalAmount, claimedAmount, remainingAmount
5+
ALTER TABLE "AidPackage" ALTER COLUMN "totalAmount" SET DATA TYPE DECIMAL(38, 18);
6+
ALTER TABLE "AidPackage" ALTER COLUMN "claimedAmount" SET DATA TYPE DECIMAL(38, 18);
7+
ALTER TABLE "AidPackage" ALTER COLUMN "remainingAmount" SET DATA TYPE DECIMAL(38, 18);
8+
9+
-- BalanceLedger: amount
10+
ALTER TABLE "BalanceLedger" ALTER COLUMN "amount" SET DATA TYPE DECIMAL(38, 18);
11+
12+
-- Claim: amount
13+
ALTER TABLE "Claim" ALTER COLUMN "amount" SET DATA TYPE DECIMAL(38, 18);
14+
15+
-- Campaign: budget
16+
ALTER TABLE "Campaign" ALTER COLUMN "budget" SET DATA TYPE DECIMAL(38, 18);

app/backend/prisma/schema.prisma

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ model AidPackage {
3434
campaignId String?
3535
campaign Campaign? @relation(fields: [campaignId], references: [id])
3636
37-
totalAmount Float @default(0)
38-
claimedAmount Float @default(0)
39-
remainingAmount Float @default(0)
37+
totalAmount Decimal @db.Decimal(38, 18) @default(0)
38+
claimedAmount Decimal @db.Decimal(38, 18) @default(0)
39+
remainingAmount Decimal @db.Decimal(38, 18) @default(0)
4040
4141
@@index([campaignId])
4242
@@index([campaignId, status])
@@ -57,7 +57,7 @@ model BalanceLedger {
5757
eventType String
5858
5959
/// Positive for lock/disburse, negative for unlock
60-
amount Float
60+
amount Decimal @db.Decimal(38, 18)
6161
6262
note String?
6363
createdAt DateTime @default(now())
@@ -224,7 +224,7 @@ model Claim {
224224
campaignId String
225225
campaign Campaign @relation(fields: [campaignId], references: [id])
226226
227-
amount Float
227+
amount Decimal @db.Decimal(38, 18)
228228
229229
recipientRef String
230230
evidenceRef String?
@@ -348,7 +348,7 @@ model Campaign {
348348
349349
status CampaignStatus @default(draft)
350350
351-
budget Float
351+
budget Decimal @db.Decimal(38, 18)
352352
353353
metadata Json?
354354

app/backend/src/campaigns/campaigns.controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ export class CampaignsController {
220220
return { error: 'Campaign not found' };
221221
}
222222
const usage = await this.budgetService.getCampaignBudgetUsage(id);
223-
const available = campaign.budget - usage.locked - usage.disbursed;
223+
const available =
224+
campaign.budget.toNumber() - usage.locked - usage.disbursed;
224225
return {
225226
campaignId: id,
226227
budget: campaign.budget,

app/backend/src/claims/cancel-and-reissue.service.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { AuditService } from '../audit/audit.service';
99
import { EncryptionService } from '../common/encryption/encryption.service';
1010
import { CancelClaimDto } from './dto/cancel-claim.dto';
1111
import { ReissueClaimDto } from './dto/reissue-claim.dto';
12-
import { ClaimStatus } from '@prisma/client';
12+
import { ClaimStatus, Prisma } from '@prisma/client';
1313
import {
1414
CLAIM_EVENT,
1515
ClaimCancelledEvent,
@@ -89,7 +89,7 @@ export class CancelAndReissueService {
8989
claimId: id,
9090
eventType: 'unlock',
9191
// Negative amount: this entry reduces the total locked balance
92-
amount: -claim.amount,
92+
amount: claim.amount.negated(),
9393
note: `Claim ${id} cancelled by ${dto.operatorId}. Reason: ${dto.reason ?? 'none'}`,
9494
},
9595
});
@@ -104,7 +104,7 @@ export class CancelAndReissueService {
104104
campaignId: claim.campaignId,
105105
operatorId: dto.operatorId,
106106
reason: dto.reason,
107-
unlockedAmount: claim.amount,
107+
unlockedAmount: claim.amount.toNumber(),
108108
timestamp: now,
109109
};
110110

@@ -185,7 +185,7 @@ export class CancelAndReissueService {
185185
campaignId: original.campaignId,
186186
claimId: originalId,
187187
eventType: 'unlock',
188-
amount: -original.amount,
188+
amount: original.amount.negated(),
189189
note: `Claim ${originalId} cancelled for reissue by ${dto.operatorId}`,
190190
},
191191
});
@@ -225,7 +225,7 @@ export class CancelAndReissueService {
225225
campaignId: original.campaignId,
226226
operatorId: dto.operatorId,
227227
reason: dto.reason ?? `Reissued as ${newClaim.id}`,
228-
unlockedAmount: original.amount,
228+
unlockedAmount: original.amount.toNumber(),
229229
timestamp: now,
230230
};
231231

@@ -235,7 +235,7 @@ export class CancelAndReissueService {
235235
originalClaimId: originalId,
236236
campaignId: original.campaignId,
237237
operatorId: dto.operatorId,
238-
amount: newAmount,
238+
amount: typeof newAmount === 'number' ? newAmount : newAmount.toNumber(),
239239
reason: dto.reason,
240240
timestamp: now,
241241
};
@@ -325,18 +325,20 @@ export class CancelAndReissueService {
325325
where: { campaignId },
326326
});
327327

328-
let lockedAmount = 0;
329-
let disbursedAmount = 0;
328+
let lockedAmount = new Prisma.Decimal(0);
329+
let disbursedAmount = new Prisma.Decimal(0);
330330

331331
for (const entry of ledger) {
332332
if (entry.eventType === 'lock' || entry.eventType === 'unlock') {
333-
lockedAmount += entry.amount; // unlock entries have negative amounts
333+
lockedAmount = lockedAmount.add(entry.amount); // unlock entries have negative amounts
334334
} else if (entry.eventType === 'disburse') {
335-
disbursedAmount += entry.amount;
335+
disbursedAmount = disbursedAmount.add(entry.amount);
336336
}
337337
}
338338

339-
const availableBudget = campaign.budget - lockedAmount - disbursedAmount;
339+
const availableBudget = campaign.budget
340+
.sub(lockedAmount)
341+
.sub(disbursedAmount);
340342

341343
return {
342344
campaignId,

app/backend/src/claims/claims.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ export class ClaimsService {
532532
claimId: claim.id,
533533
packageId: claim.campaignId,
534534
status: claim.status,
535-
amount: claim.amount,
535+
amount: claim.amount.toNumber(),
536536
timestamp: claim.createdAt.toISOString(),
537537
tokenAddress,
538538
recipientRef: claim.recipientRef,
@@ -748,7 +748,10 @@ export class ClaimsService {
748748
where.OR = [
749749
{
750750
campaign: {
751-
metadata: { path: ['tokenAddress'] as any, equals: query.tokenAddress },
751+
metadata: {
752+
path: ['tokenAddress'] as any,
753+
equals: query.tokenAddress,
754+
},
752755
},
753756
},
754757
];

app/backend/src/common/budget/budget.service.spec.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Decimal } from '@prisma/client/runtime/library';
12
import { BudgetService } from './budget.service';
23
import { PrismaService } from '../../prisma/prisma.service';
34

@@ -18,13 +19,13 @@ describe('BudgetService', () => {
1819
it('should allow within budget', async () => {
1920
(prisma.campaign.findUnique as jest.Mock).mockResolvedValue({
2021
id: 'c1',
21-
budget: 100,
22+
budget: new Decimal(100),
2223
});
2324

2425
const aggregateMock = prisma.balanceLedger.aggregate as jest.Mock;
2526
aggregateMock
26-
.mockResolvedValueOnce({ _sum: { amount: 30 } }) // locked
27-
.mockResolvedValueOnce({ _sum: { amount: 20 } }); // disbursed
27+
.mockResolvedValueOnce({ _sum: { amount: new Decimal(30) } })
28+
.mockResolvedValueOnce({ _sum: { amount: new Decimal(20) } });
2829

2930
await expect(
3031
budgetService.assertWithinBudget('c1', 40),
@@ -34,13 +35,13 @@ describe('BudgetService', () => {
3435
it('should reject if over budget', async () => {
3536
(prisma.campaign.findUnique as jest.Mock).mockResolvedValue({
3637
id: 'c1',
37-
budget: 100,
38+
budget: new Decimal(100),
3839
});
3940

4041
const aggregateMock = prisma.balanceLedger.aggregate as jest.Mock;
4142
aggregateMock
42-
.mockResolvedValueOnce({ _sum: { amount: 60 } }) // locked
43-
.mockResolvedValueOnce({ _sum: { amount: 30 } }); // disbursed
43+
.mockResolvedValueOnce({ _sum: { amount: new Decimal(60) } })
44+
.mockResolvedValueOnce({ _sum: { amount: new Decimal(30) } });
4445

4546
await expect(budgetService.assertWithinBudget('c1', 20)).rejects.toThrow(
4647
'Campaign funding cap exceeded',

app/backend/src/common/budget/budget.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ export class BudgetService {
2929
},
3030
});
3131
return {
32-
locked: locked._sum.amount || 0,
33-
disbursed: disbursed._sum.amount || 0,
32+
locked: locked._sum.amount?.toNumber() ?? 0,
33+
disbursed: disbursed._sum.amount?.toNumber() ?? 0,
3434
};
3535
}
3636

@@ -44,7 +44,7 @@ export class BudgetService {
4444
if (!campaign) throw new BadRequestException('Campaign not found');
4545
const usage = await this.getCampaignBudgetUsage(campaignId);
4646
const total = usage.locked + usage.disbursed + newAmount;
47-
if (total > campaign.budget) {
47+
if (total > campaign.budget.toNumber()) {
4848
throw new BadRequestException('Campaign funding cap exceeded');
4949
}
5050
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
CallHandler,
3+
ExecutionContext,
4+
Injectable,
5+
NestInterceptor,
6+
} from '@nestjs/common';
7+
import { Observable, map } from 'rxjs';
8+
9+
/**
10+
* Recursively converts Prisma Decimal values (which are objects with a
11+
* `toString()` method) to their string representation in API responses.
12+
*
13+
* This ensures monetary values are serialized as strings (e.g., "1000.50")
14+
* rather than floating-point numbers, preventing precision loss in clients.
15+
*/
16+
function serializeDecimals(obj: unknown): unknown {
17+
if (obj === null || obj === undefined) {
18+
return obj;
19+
}
20+
21+
// Prisma Decimal objects have a `toString()` method and a `valueOf()` method
22+
if (
23+
typeof obj === 'object' &&
24+
'toString' in obj &&
25+
typeof (obj as { valueOf: () => unknown }).valueOf === 'function'
26+
) {
27+
const str = (obj as { toString: () => string }).toString();
28+
// Check if it looks like a decimal number (contains only digits, dots, and optional minus sign)
29+
if (/^-?\d+(\.\d+)?$/.test(str)) {
30+
return str;
31+
}
32+
}
33+
34+
if (Array.isArray(obj)) {
35+
return obj.map(serializeDecimals);
36+
}
37+
38+
if (typeof obj === 'object') {
39+
const result: Record<string, unknown> = {};
40+
for (const [key, value] of Object.entries(obj)) {
41+
result[key] = serializeDecimals(value);
42+
}
43+
return result;
44+
}
45+
46+
return obj;
47+
}
48+
49+
@Injectable()
50+
export class DecimalSerializerInterceptor implements NestInterceptor {
51+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
52+
return next.handle().pipe(
53+
map((data) => serializeDecimals(data)),
54+
);
55+
}
56+
}

app/backend/src/onchain/ledger-reconciliation.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ export class LedgerReconciliationService {
156156
}
157157

158158
// Check amount mismatch
159-
const amountDiff = Math.abs(onChainEntry.amount - storedEntry.amount);
159+
const amountDiff = Math.abs(
160+
onChainEntry.amount - storedEntry.amount.toNumber(),
161+
);
160162
const amountDiffPercent = (amountDiff / onChainEntry.amount) * 100;
161163

162164
if (amountDiffPercent > thresholdPercent) {

app/backend/test/coverage-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
["src/auth/roles.guard.ts",100,-1,100,100],
1616
["src/campaigns/campaigns.controller.ts",-29,-16,-8,-29],
1717
["src/campaigns/campaigns.service.ts",-30,-41,-6,-36],
18-
["src/claims/cancel-and-reissue.service.ts",-76,-51,-10,-78],
18+
["src/claims/cancel-and-reissue.service.ts",-76,-53,-10,-78],
1919
["src/claims/claim-export.controller.ts",-8,-2,-1,-8],
2020
["src/claims/claim-lifecycle.controller.ts",-13,-12,-12,-13],
2121
["src/claims/claim-receipt.controller.ts",-2,-4,-2,-2],

0 commit comments

Comments
 (0)