Skip to content

Commit b3e146f

Browse files
fix: PR A — concurrency + money bugs (round 4, bugs 1-4) (#78)
Bug 1 (CRITICAL): payment capture/refund/void race - Conditional UPDATE-by-status claims the row atomically - Stripe idempotency keys (cap_/ref_/void_) prevent gateway double-charge - Two-phase pattern: short tx claims state, network call outside, tx finalizes Bug 2 (HIGH): folio.transferCharge non-atomic - Wrapped in db.transaction, both folios locked FOR UPDATE in id-order - Helpers accept optional tx; recalculateBalance uses decimal.js (no parseFloat) Bug 3 (HIGH): transferToCityLedger non-atomic - All 4 steps wrapped in db.transaction - Monetary comparisons use decimal.js on string representations Bug 4 (HIGH): night audit concurrent execution - Unique index on (property_id, business_date) - onConflictDoNothing + re-select: completed → ConflictException, running → return as active audit All financial math now uses decimal.js on Drizzle's string numerics. No parseFloat on monetary values. 552/552 tests passing, build + typecheck clean. Co-authored-by: Dušan Milićević <dusanmilicevic33@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 952e3bb commit b3e146f

14 files changed

Lines changed: 588 additions & 204 deletions

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@nestjs/websockets": "^10.0.0",
3232
"class-transformer": "^0.5.1",
3333
"class-validator": "^0.14.1",
34+
"decimal.js": "^10.4.3",
3435
"drizzle-orm": "^0.39.0",
3536
"eventemitter2": "^6.4.9",
3637
"fast-xml-parser": "^5.5.10",

apps/api/src/modules/folio/folio-routing.service.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const mockFolioService = {
4646
};
4747

4848
function createMockDb(returnData: any[] = [mockReservation]) {
49-
return {
49+
const db: any = {
5050
select: vi.fn().mockImplementation(() => ({
5151
from: vi.fn().mockReturnValue({
5252
where: vi.fn().mockReturnValue({
@@ -68,6 +68,8 @@ function createMockDb(returnData: any[] = [mockReservation]) {
6868
}),
6969
delete: vi.fn(),
7070
};
71+
db.transaction = (cb: any) => cb(db);
72+
return db;
7173
}
7274

7375
describe('FolioRoutingService', () => {
@@ -213,6 +215,7 @@ describe('FolioRoutingService', () => {
213215
companyName: 'Acme Corp',
214216
paymentTermsDays: 'NET30',
215217
}),
218+
expect.anything(), // Bug 3: now called inside db.transaction with tx
216219
);
217220
expect(result.transferredAmount).toBe('300.00');
218221
expect(result.cityLedgerFolioId).toBe('folio-cl-001');

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

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
NotFoundException,
55
} from '@nestjs/common';
66
import { eq, and } from 'drizzle-orm';
7+
import Decimal from 'decimal.js';
78
import { folios, reservations, payments } from '@haip/database';
89
import { DRIZZLE } from '../../database/database.module';
910
import { FolioService } from './folio.service';
@@ -77,53 +78,70 @@ export class FolioRoutingService {
7778
dto: TransferCityLedgerDto,
7879
) {
7980
const sourceFolio = await this.folioService.findById(folioId, propertyId);
80-
const remainingBalance = parseFloat(sourceFolio.balance);
81+
// Monetary compare on string representation via decimal.js (numeric-as-string).
82+
const remainingBalance = new Decimal(sourceFolio.balance);
8183

82-
if (remainingBalance <= 0) {
84+
if (remainingBalance.lte(0)) {
8385
return { message: 'No outstanding balance to transfer' };
8486
}
8587

86-
// Create city ledger folio
87-
const cityLedgerFolio = await this.folioService.create({
88-
propertyId,
89-
guestId: sourceFolio.guestId,
90-
type: 'city_ledger',
91-
currencyCode: sourceFolio.currencyCode,
92-
companyName: dto.companyName,
93-
billingAddress: dto.billingAddress,
94-
paymentTermsDays: dto.paymentTermsDays,
95-
});
88+
const amountStr = remainingBalance.toFixed(2);
89+
90+
// Bug 3: wrap all mutating steps in a single transaction so partial failure
91+
// (e.g. CL folio created but payment insert fails) is impossible.
92+
// Idempotency-by-transferId is out of scope for this PR.
93+
const { cityLedgerFolio } = await this.db.transaction(async (tx: any) => {
94+
// Create city ledger folio
95+
const cityLedgerFolio = await this.folioService.create(
96+
{
97+
propertyId,
98+
guestId: sourceFolio.guestId,
99+
type: 'city_ledger',
100+
currencyCode: sourceFolio.currencyCode,
101+
companyName: dto.companyName,
102+
billingAddress: dto.billingAddress,
103+
paymentTermsDays: dto.paymentTermsDays,
104+
},
105+
tx,
106+
);
107+
108+
// Record city_ledger payment on the source folio (zeroes out the guest folio)
109+
await tx
110+
.insert(payments)
111+
.values({
112+
folioId,
113+
propertyId,
114+
method: 'city_ledger',
115+
amount: amountStr,
116+
currencyCode: sourceFolio.currencyCode,
117+
status: 'captured',
118+
processedAt: new Date(),
119+
notes: `Transferred to city ledger: ${dto.companyName}`,
120+
});
121+
122+
await this.folioService.recalculateBalance(folioId, propertyId, tx);
123+
124+
// Post matching charge on the city ledger folio
125+
await this.folioService.postCharge(
126+
cityLedgerFolio.id,
127+
{
128+
propertyId,
129+
type: 'fee',
130+
description: `Transfer from folio ${sourceFolio.folioNumber}`,
131+
amount: amountStr,
132+
currencyCode: sourceFolio.currencyCode,
133+
serviceDate: new Date().toISOString(),
134+
},
135+
tx,
136+
);
96137

97-
// Record city_ledger payment on the source folio (zeroes out the guest folio)
98-
await this.db
99-
.insert(payments)
100-
.values({
101-
folioId,
102-
propertyId,
103-
method: 'city_ledger',
104-
amount: remainingBalance.toFixed(2),
105-
currencyCode: sourceFolio.currencyCode,
106-
status: 'captured',
107-
processedAt: new Date(),
108-
notes: `Transferred to city ledger: ${dto.companyName}`,
109-
});
110-
111-
await this.folioService.recalculateBalance(folioId, propertyId);
112-
113-
// Post matching charge on the city ledger folio
114-
await this.folioService.postCharge(cityLedgerFolio.id, {
115-
propertyId,
116-
type: 'fee',
117-
description: `Transfer from folio ${sourceFolio.folioNumber}`,
118-
amount: remainingBalance.toFixed(2),
119-
currencyCode: sourceFolio.currencyCode,
120-
serviceDate: new Date().toISOString(),
138+
return { cityLedgerFolio };
121139
});
122140

123141
return {
124142
sourceFolioId: folioId,
125143
cityLedgerFolioId: cityLedgerFolio.id,
126-
transferredAmount: remainingBalance.toFixed(2),
144+
transferredAmount: amountStr,
127145
};
128146
}
129147
}

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -233,20 +233,23 @@ describe('FolioService', () => {
233233
it('should transfer charge between folios', async () => {
234234
let selectCallCount = 0;
235235
const targetFolio = { ...mockFolio, id: 'folio-002' };
236-
const db = {
236+
const thenResolver = (resolve: any) => {
237+
selectCallCount++;
238+
// Bug 2: new order is deterministic by folio.id: folio-001 (source), folio-002 (target), charge lookup, then recalc sums.
239+
if (selectCallCount === 1) resolve([mockFolio]);
240+
else if (selectCallCount === 2) resolve([targetFolio]);
241+
else if (selectCallCount === 3) resolve([mockCharge]);
242+
else resolve([{ total: '0' }]);
243+
};
244+
const whereChain = () => ({
245+
then: thenResolver,
246+
// .for('update') returns a thenable that also resolves the current row
247+
for: vi.fn().mockReturnValue({ then: thenResolver }),
248+
});
249+
const db: any = {
237250
select: vi.fn().mockImplementation(() => ({
238251
from: vi.fn().mockReturnValue({
239-
where: vi.fn().mockReturnValue({
240-
then: (resolve: any) => {
241-
selectCallCount++;
242-
// 1: source folio, 2: target folio, 3: charge lookup
243-
// 4-5: recalculate charge sums, 6-7: recalculate payment sums
244-
if (selectCallCount === 1) resolve([mockFolio]);
245-
else if (selectCallCount === 2) resolve([targetFolio]);
246-
else if (selectCallCount === 3) resolve([mockCharge]);
247-
else resolve([{ total: '0' }]);
248-
},
249-
}),
252+
where: vi.fn().mockImplementation(whereChain),
250253
}),
251254
})),
252255
insert: vi.fn(),
@@ -259,6 +262,8 @@ describe('FolioService', () => {
259262
}),
260263
delete: vi.fn(),
261264
};
265+
// Bug 2: transferCharge wraps everything in db.transaction — pass tx = db.
266+
db.transaction = (cb: any) => cb(db);
262267

263268
const module: TestingModule = await Test.createTestingModule({
264269
providers: [

0 commit comments

Comments
 (0)