Skip to content

Commit e36ef6d

Browse files
fix: PR D — decimal.js migration across monetary math (round 4, bug 11) (#81)
Replace float arithmetic on monetary values with decimal.js operating on Drizzle's string numeric representation. Round only at persist/display boundary. Primary fix (Bug 11): tax engine - calculateTaxes compound base: runningBase.plus(rounded) exact each step - backCalculateFromInclusive: gross / (1 + rate) now bit-exact - Percentage rate application Decimal-safe Full sweep — 11 business-logic files: - folio.service (reversal/negation, tax charge reversal) - payment.service (gateway amount, partial-refund idempotency key stability) - reservation.service (120% deposit, express-checkout balance guard) - reports.service (daily/financial/trend aggregation, ADR, RevPAR) - night-audit.service (totalRoom/totalTax, ADR, RevPAR) - connect-booking/search/insights.service (rate shopping, nightly breakdown, cancellation first-night penalty sums to totalAmount exactly) - channel/ari.service + rate-parity.service (override percentage math exact) parseFloat instances: 44 → 19 (25 removed). Remaining 19 intentionally kept: - tests (6), statistical/ML agent inputs (9), OTA XML boundary parsers (2), confidence probabilities (2). Documented by decision rule. 562/562 tests pass unchanged — existing tax assertions lived in float-safe zone; Decimal output matched at 2-decimal precision. Co-authored-by: Dušan Milićević <dusanmilicevic33@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b0c14c commit e36ef6d

11 files changed

Lines changed: 177 additions & 120 deletions

File tree

apps/api/src/modules/channel/ari.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, Inject } from '@nestjs/common';
22
import { OnEvent } from '@nestjs/event-emitter';
33
import { eq, and } from 'drizzle-orm';
4+
import Decimal from 'decimal.js';
45
import { ariSyncLogs, ratePlans, rateRestrictions } from '@haip/database';
56
import { DRIZZLE } from '../../database/database.module';
67
import { ChannelAdapterFactory } from './channel-adapter.factory';
@@ -166,7 +167,7 @@ export class AriService {
166167
const end = new Date(endDate);
167168
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
168169
const dateStr = d.toISOString().split('T')[0]!;
169-
const baseRate = parseFloat(ratePlan.baseAmount);
170+
const baseRate = new Decimal(ratePlan.baseAmount).toNumber();
170171

171172
rateItems.push({
172173
channelRoomCode: roomMapping.channelRoomCode,

apps/api/src/modules/channel/rate-parity.service.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, Inject } from '@nestjs/common';
22
import { eq, and, gte, lte } from 'drizzle-orm';
3+
import Decimal from 'decimal.js';
34
import { ratePlans, channelConnections } from '@haip/database';
45
import { DRIZZLE } from '../../database/database.module';
56

@@ -65,7 +66,7 @@ export class RateParityService {
6566
const results: RateParityResult[] = [];
6667

6768
for (const plan of plans) {
68-
const baseAmount = parseFloat(plan.baseAmount);
69+
const baseAmount = new Decimal(plan.baseAmount).toNumber();
6970
const channels: RateParityResult['channels'] = [];
7071
let parityViolations = 0;
7172

@@ -83,16 +84,16 @@ export class RateParityService {
8384
const overrides = (config['rateOverrides'] as RateOverride[] | undefined) ?? [];
8485
const override = overrides.find((o) => o.ratePlanId === plan.id);
8586

86-
let effectiveRate = baseAmount;
87+
let effectiveRateDec = new Decimal(baseAmount);
8788
let hasOverride = false;
8889

8990
if (override) {
9091
hasOverride = true;
91-
effectiveRate = this.applyOverride(baseAmount, override);
92+
effectiveRateDec = this.applyOverrideDecimal(effectiveRateDec, override);
9293
}
9394

94-
const variance = Math.abs(effectiveRate - baseAmount);
95-
const isParity = variance < 0.01; // Within 1 cent tolerance
95+
const varianceDec = effectiveRateDec.minus(baseAmount).abs();
96+
const isParity = varianceDec.lt('0.01'); // Within 1 cent tolerance
9697

9798
if (!isParity) {
9899
parityViolations++;
@@ -103,10 +104,10 @@ export class RateParityService {
103104
channelCode: conn.channelCode,
104105
channelName: conn.channelName,
105106
channelRateCode: mapping.channelRateCode,
106-
effectiveRate: Math.round(effectiveRate * 100) / 100,
107+
effectiveRate: Number(effectiveRateDec.toFixed(2)),
107108
hasOverride,
108109
isParity,
109-
variance: Math.round(variance * 100) / 100,
110+
variance: Number(varianceDec.toFixed(2)),
110111
});
111112
}
112113

@@ -152,10 +153,11 @@ export class RateParityService {
152153
);
153154

154155
if (!conn) {
155-
return { baseAmount: parseFloat(plan.baseAmount), effectiveRate: parseFloat(plan.baseAmount), hasOverride: false };
156+
const base = new Decimal(plan.baseAmount).toNumber();
157+
return { baseAmount: base, effectiveRate: base, hasOverride: false };
156158
}
157159

158-
const baseAmount = parseFloat(plan.baseAmount);
160+
const baseAmount = new Decimal(plan.baseAmount).toNumber();
159161
const config = (conn.config ?? {}) as Record<string, unknown>;
160162
const overrides = (config['rateOverrides'] as RateOverride[] | undefined) ?? [];
161163

@@ -166,10 +168,10 @@ export class RateParityService {
166168
return { baseAmount, effectiveRate: baseAmount, hasOverride: false };
167169
}
168170

169-
const effectiveRate = this.applyOverride(baseAmount, applicableOverride);
171+
const effectiveRateDec = this.applyOverrideDecimal(new Decimal(baseAmount), applicableOverride);
170172
return {
171173
baseAmount,
172-
effectiveRate: Math.round(effectiveRate * 100) / 100,
174+
effectiveRate: Number(effectiveRateDec.toFixed(2)),
173175
hasOverride: true,
174176
override: applicableOverride,
175177
};
@@ -271,11 +273,17 @@ export class RateParityService {
271273
// --- Private Helpers ---
272274

273275
private applyOverride(baseAmount: number, override: RateOverride): number {
276+
return this.applyOverrideDecimal(new Decimal(baseAmount), override).toNumber();
277+
}
278+
279+
// Decimal-safe override math — preferred internally to avoid float drift on
280+
// percentage adjustments (e.g. 10% of 127.35 drifts under JS multiply).
281+
private applyOverrideDecimal(baseAmount: Decimal, override: RateOverride): Decimal {
274282
if (override.adjustmentType === 'percentage') {
275-
return baseAmount * (1 + override.adjustmentValue / 100);
283+
return baseAmount.times(new Decimal(1).plus(new Decimal(override.adjustmentValue).div(100)));
276284
}
277285
// Fixed adjustment
278-
return baseAmount + override.adjustmentValue;
286+
return baseAmount.plus(override.adjustmentValue);
279287
}
280288

281289
private findApplicableOverride(

apps/api/src/modules/connect/connect-booking.service.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
22
import { eq, and } from 'drizzle-orm';
3+
import Decimal from 'decimal.js';
34
import { bookings, reservations, guests, ratePlans, roomTypes, folios, rooms } from '@haip/database';
45
import { DRIZZLE } from '../../database/database.module';
56
import { AvailabilityService } from '../reservation/availability.service';
@@ -60,8 +61,11 @@ export class ConnectBookingService {
6061
const arrival = new Date(dto.checkIn);
6162
const departure = new Date(dto.checkOut);
6263
const nights = Math.ceil((departure.getTime() - arrival.getTime()) / (1000 * 60 * 60 * 24));
63-
const baseAmount = parseFloat(ratePlan.baseAmount);
64-
const totalAmount = baseAmount * nights;
64+
// Monetary math via Decimal (baseAmount is a numeric string from PG)
65+
const baseAmountDec = new Decimal(ratePlan.baseAmount);
66+
const totalAmountDec = baseAmountDec.times(nights);
67+
const baseAmount = baseAmountDec.toNumber();
68+
const totalAmount = totalAmountDec.toNumber();
6569

6670
// 5. Generate confirmation number
6771
const confirmationNumber = `HAIP-${Date.now().toString(36).toUpperCase()}-${randomUUID().slice(0, 4).toUpperCase()}`;
@@ -91,7 +95,7 @@ export class ConnectBookingService {
9195
nights,
9296
roomTypeId: dto.roomTypeId,
9397
ratePlanId: dto.ratePlanId,
94-
totalAmount: totalAmount.toString(),
98+
totalAmount: totalAmountDec.toFixed(2),
9599
currencyCode: ratePlan.currencyCode,
96100
adults: dto.adults,
97101
children: dto.children ?? 0,
@@ -209,12 +213,12 @@ export class ConnectBookingService {
209213
checkIn: reservation.arrivalDate,
210214
checkOut: reservation.departureDate,
211215
roomType: roomType?.name ?? 'Unknown',
212-
rateAmount: parseFloat(reservation.totalAmount),
216+
rateAmount: new Decimal(reservation.totalAmount).toNumber(),
213217
currencyCode: reservation.currencyCode,
214218
roomAssigned: !!reservation.roomId,
215219
roomNumber,
216220
folioExists: !!folio,
217-
folioBalance: folio ? parseFloat(folio.balance) : undefined,
221+
folioBalance: folio ? new Decimal(folio.balance).toNumber() : undefined,
218222
lastModified: reservation.updatedAt?.toISOString() ?? reservation.createdAt.toISOString(),
219223
verifiedAt: new Date().toISOString(),
220224
};
@@ -247,8 +251,9 @@ export class ConnectBookingService {
247251
}
248252

249253
const updateFields: Record<string, any> = { updatedAt: new Date() };
250-
let costDifference = 0;
251-
const previousAmount = parseFloat(reservation.totalAmount);
254+
let costDifferenceDec = new Decimal(0);
255+
const previousAmountDec = new Decimal(reservation.totalAmount);
256+
const previousAmount = previousAmountDec.toNumber();
252257

253258
// Handle guest detail updates
254259
if (dto.guestFirstName || dto.guestLastName) {
@@ -302,17 +307,17 @@ export class ConnectBookingService {
302307
const arrival = new Date(newCheckIn);
303308
const departure = new Date(newCheckOut);
304309
const nights = Math.ceil((departure.getTime() - arrival.getTime()) / (1000 * 60 * 60 * 24));
305-
const newTotal = parseFloat(ratePlan.baseAmount) * nights;
310+
const newTotalDec = new Decimal(ratePlan.baseAmount).times(nights);
306311

307312
updateFields['arrivalDate'] = newCheckIn;
308313
updateFields['departureDate'] = newCheckOut;
309314
updateFields['nights'] = nights;
310315
updateFields['roomTypeId'] = newRoomTypeId;
311316
updateFields['ratePlanId'] = newRatePlanId;
312-
updateFields['totalAmount'] = newTotal.toString();
317+
updateFields['totalAmount'] = newTotalDec.toFixed(2);
313318
updateFields['currencyCode'] = ratePlan.currencyCode;
314319

315-
costDifference = newTotal - previousAmount;
320+
costDifferenceDec = newTotalDec.minus(previousAmountDec);
316321
}
317322

318323
// Apply update
@@ -336,9 +341,9 @@ export class ConnectBookingService {
336341
confirmationNumber,
337342
reservationId: reservation.id,
338343
status: updated.status,
339-
previousAmount: Math.round(previousAmount * 100) / 100,
340-
newAmount: Math.round(parseFloat(updated.totalAmount) * 100) / 100,
341-
costDifference: Math.round(costDifference * 100) / 100,
344+
previousAmount: Number(previousAmountDec.toFixed(2)),
345+
newAmount: Number(new Decimal(updated.totalAmount).toFixed(2)),
346+
costDifference: Number(costDifferenceDec.toFixed(2)),
342347
modifiedAt: new Date().toISOString(),
343348
};
344349
}
@@ -454,15 +459,16 @@ export class ConnectBookingService {
454459
taxRate: number,
455460
) {
456461
const breakdown = [];
462+
const baseAmountDec = new Decimal(baseAmount);
463+
const taxPerNightDec = baseAmountDec.times(taxRate).div(100);
457464
for (let i = 0; i < nights; i++) {
458465
const date = new Date(arrival);
459466
date.setDate(date.getDate() + i);
460467
const dateStr = date.toISOString().split('T')[0]!;
461-
const tax = baseAmount * (taxRate / 100);
462468
breakdown.push({
463469
date: dateStr,
464-
rate: Math.round(baseAmount * 100) / 100,
465-
tax: Math.round(tax * 100) / 100,
470+
rate: Number(baseAmountDec.toFixed(2)),
471+
tax: Number(taxPerNightDec.toFixed(2)),
466472
});
467473
}
468474
return breakdown;
@@ -476,7 +482,9 @@ export class ConnectBookingService {
476482
}
477483

478484
private calculateCancellationPenalty(ratePlan: any, reservation: any) {
479-
const totalAmount = parseFloat(reservation.totalAmount);
485+
// Money math via Decimal; refundAmount / penaltyAmount are displayed as currency
486+
const totalAmountDec = new Decimal(reservation.totalAmount);
487+
const totalAmount = totalAmountDec.toNumber();
480488

481489
// Non-refundable rate
482490
if (ratePlan?.type === 'promotional') {
@@ -504,11 +512,11 @@ export class ConnectBookingService {
504512

505513
// First night penalty
506514
const nights = reservation.nights || 1;
507-
const firstNightAmount = totalAmount / nights;
515+
const firstNightAmountDec = totalAmountDec.div(nights);
508516
return {
509517
penaltyApplied: true,
510-
penaltyAmount: Math.round(firstNightAmount * 100) / 100,
511-
refundAmount: Math.round((totalAmount - firstNightAmount) * 100) / 100,
518+
penaltyAmount: Number(firstNightAmountDec.toFixed(2)),
519+
refundAmount: Number(totalAmountDec.minus(firstNightAmountDec).toFixed(2)),
512520
policyDescription: 'First night charge applies — cancelled within 24 hours of check-in.',
513521
};
514522
}

apps/api/src/modules/connect/connect-insights.service.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, Inject } from '@nestjs/common';
22
import { eq, and, sql, gte, lte } from 'drizzle-orm';
3+
import Decimal from 'decimal.js';
34
import {
45
properties,
56
reservations,
@@ -47,14 +48,15 @@ export class ConnectInsightsService {
4748
const roomsAvailable = Math.max(0, totalRooms - roomsSold);
4849
const occupancyRate = totalRooms > 0 ? (roomsSold / totalRooms) * 100 : 0;
4950

50-
// Calculate ADR
51-
const totalRevenue = soldReservations.reduce((sum: number, r: any) => {
52-
const amount = parseFloat(r.totalAmount) || 0;
51+
// Calculate ADR via Decimal — displayed as currency
52+
const totalRevenueDec = soldReservations.reduce((sum: Decimal, r: any) => {
53+
const amount = r.totalAmount ? new Decimal(r.totalAmount) : new Decimal(0);
5354
const nights = r.nights || 1;
54-
return sum + (amount / nights);
55-
}, 0);
56-
const adr = roomsSold > 0 ? totalRevenue / roomsSold : 0;
57-
const revpar = totalRooms > 0 ? totalRevenue / totalRooms : 0;
55+
return sum.plus(amount.div(nights));
56+
}, new Decimal(0));
57+
const totalRevenue = totalRevenueDec.toNumber();
58+
const adr = roomsSold > 0 ? totalRevenueDec.div(roomsSold).toNumber() : 0;
59+
const revpar = totalRooms > 0 ? totalRevenueDec.div(totalRooms).toNumber() : 0;
5860

5961
// Count today's new reservations and cancellations
6062
const reservationsToday = await this.db
@@ -103,7 +105,7 @@ export class ConnectInsightsService {
103105
roomsSold,
104106
reservationsToday: Number(reservationsToday[0]?.count ?? 0),
105107
cancellationsToday: Number(cancellationsToday[0]?.count ?? 0),
106-
currentBarRate: barRate ? parseFloat(barRate.baseAmount) : 0,
108+
currentBarRate: barRate ? new Decimal(barRate.baseAmount).toNumber() : 0,
107109
barRatePlanId: barRate?.id,
108110
suggestions,
109111
};

apps/api/src/modules/connect/connect-search.service.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, Inject } from '@nestjs/common';
22
import { eq, and, gte, lte } from 'drizzle-orm';
3+
import Decimal from 'decimal.js';
34
import { properties, roomTypes, ratePlans, rateRestrictions } from '@haip/database';
45
import { DRIZZLE } from '../../database/database.module';
56
import { AvailabilityService } from '../reservation/availability.service';
@@ -270,7 +271,7 @@ export class ConnectSearchService {
270271
checkOut: string,
271272
taxRate: number,
272273
) {
273-
const baseAmount = parseFloat(plan.baseAmount);
274+
const baseAmount = new Decimal(plan.baseAmount).toNumber();
274275

275276
// Get restrictions for the date range
276277
const restrictions = await this.db
@@ -307,34 +308,36 @@ export class ConnectSearchService {
307308
if (minLos && nights < minLos) return null;
308309
if (maxLos && maxLos !== Infinity && nights > maxLos) return null;
309310

310-
// Build nightly breakdown
311+
// Build nightly breakdown — Decimal for all per-night money math
311312
const nightlyBreakdown = [];
312-
let totalAmount = 0;
313+
let totalAmountDec = new Decimal(0);
314+
const taxRateDec = new Decimal(taxRate).div(100);
313315
for (let i = 0; i < nights; i++) {
314316
const date = new Date(arrival);
315317
date.setDate(date.getDate() + i);
316318
const dateStr = date.toISOString().split('T')[0]!;
317319

318320
// Check for day-of-week overrides
319321
const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
320-
let nightRate = baseAmount;
322+
let nightRateDec = new Decimal(baseAmount);
321323

322324
for (const restriction of restrictions) {
323325
const overrides = (restriction.dayOfWeekOverrides ?? {}) as Record<string, number>;
324326
if (overrides[dayName]) {
325-
nightRate = baseAmount + overrides[dayName]!;
327+
nightRateDec = new Decimal(baseAmount).plus(overrides[dayName]!);
326328
}
327329
}
328330

329-
const taxAmount = nightRate * (taxRate / 100);
331+
const taxAmountDec = nightRateDec.times(taxRateDec);
330332
nightlyBreakdown.push({
331333
date: dateStr,
332-
baseRate: Math.round(nightRate * 100) / 100,
333-
taxAmount: Math.round(taxAmount * 100) / 100,
334-
totalRate: Math.round((nightRate + taxAmount) * 100) / 100,
334+
baseRate: Number(nightRateDec.toFixed(2)),
335+
taxAmount: Number(taxAmountDec.toFixed(2)),
336+
totalRate: Number(nightRateDec.plus(taxAmountDec).toFixed(2)),
335337
});
336-
totalAmount += nightRate + taxAmount;
338+
totalAmountDec = totalAmountDec.plus(nightRateDec).plus(taxAmountDec);
337339
}
340+
const totalAmount = Number(totalAmountDec.toFixed(2));
338341

339342
// Build cancellation policy
340343
const cancellationPolicy = this.buildCancellationPolicy(plan);
@@ -344,7 +347,7 @@ export class ConnectSearchService {
344347
ratePlanName: plan.name,
345348
ratePlanCode: plan.code,
346349
rateType: plan.type,
347-
totalAmount: Math.round(totalAmount * 100) / 100,
350+
totalAmount,
348351
currencyCode: plan.currencyCode,
349352
nightlyBreakdown,
350353
cancellationPolicy,

apps/api/src/modules/folio/folio.service.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class FolioService {
107107
if (folio.status !== 'open') {
108108
throw new BadRequestException('Folio is not open');
109109
}
110-
if (Math.abs(parseFloat(folio.balance)) > 0.01) {
110+
if (new Decimal(folio.balance).abs().gt('0.01')) {
111111
throw new BadRequestException(
112112
`Folio balance must be zero to settle (current: ${folio.balance})`,
113113
);
@@ -390,8 +390,8 @@ export class FolioService {
390390
throw new BadRequestException('Charge has already been reversed');
391391
}
392392

393-
const negatedAmount = (parseFloat(original.amount) * -1).toFixed(2);
394-
const negatedTax = (parseFloat(original.taxAmount) * -1).toFixed(2);
393+
const negatedAmount = new Decimal(original.amount).negated().toFixed(2);
394+
const negatedTax = new Decimal(original.taxAmount).negated().toFixed(2);
395395

396396
const [reversal] = await this.db
397397
.insert(charges)
@@ -440,7 +440,7 @@ export class FolioService {
440440
folioId,
441441
type: 'tax',
442442
description: `Reversal: ${taxCharge.description}`,
443-
amount: (parseFloat(taxCharge.amount) * -1).toFixed(2),
443+
amount: new Decimal(taxCharge.amount).negated().toFixed(2),
444444
currencyCode: taxCharge.currencyCode,
445445
taxAmount: '0',
446446
taxRate: taxCharge.taxRate,

0 commit comments

Comments
 (0)