Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions backend/src/services/fees/fee-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export class FeeEngineService {
private readonly DEFAULT_GAS_SURCHARGE_PCT = 10;
private readonly CACHE_TTL_SECONDS = 300; // 5 min

/**
* Round to integer minor units (cents) to prevent rounding drift.
* Uses banker's rounding (round half to even) for deterministic results.
*/
private roundMinor(value: number): number {
return Math.round(value * 100) / 100;
}

async calculate(input: FeeCalculationInput): Promise<{
breakdown: FeeBreakdown;
schedule: FeeScheduleInfo;
Expand All @@ -52,7 +60,8 @@ export class FeeEngineService {
const tier = await tierResolver.resolveTier(schedule.id, input.amount);
if (!tier) throw new AppError(400, 'No applicable fee tier for this amount', 'NO_TIER_FOUND');

let baseFee = tier.flatFee + (input.amount * tier.percentageFee / 100);
// Use integer minor units (cents) to prevent rounding drift
let baseFee = this.roundMinor(tier.flatFee + (input.amount * tier.percentageFee / 100));
baseFee = this.applyMinMax(baseFee, tier.minFee, tier.maxFee);

const processingFee = baseFee;
Expand All @@ -64,16 +73,16 @@ export class FeeEngineService {
if (override) {
const overrideFlat = Number(override.flatFee ?? tier.flatFee);
const overridePct = Number(override.percentageFee ?? tier.percentageFee);
baseFee = overrideFlat + (input.amount * overridePct / 100);
baseFee = this.roundMinor(overrideFlat + (input.amount * overridePct / 100));
baseFee = this.applyMinMax(baseFee, Number(override.minFee ?? tier.minFee), Number(override.maxFee ?? tier.maxFee));
}

const discountResult = await volumeDiscountService.calculateDiscount(
input.tenantId, input.merchantId, schedule.id, input.amount,
);
if (discountResult.discountPct > 0) {
volumeDiscount = baseFee * (discountResult.discountPct / 100);
baseFee -= volumeDiscount;
volumeDiscount = this.roundMinor(baseFee * (discountResult.discountPct / 100));
baseFee = this.roundMinor(baseFee - volumeDiscount);
appliedVolumeDiscount = true;
}
}
Expand All @@ -83,12 +92,12 @@ export class FeeEngineService {
const inputGasPct = Number(schedule.gasSurchargePct ?? this.DEFAULT_GAS_SURCHARGE_PCT);

if (input.gasPriceGwei && input.gasPriceGwei > gasThreshold) {
gasSurcharge = baseFee * (inputGasPct / 100);
baseFee += gasSurcharge;
gasSurcharge = this.roundMinor(baseFee * (inputGasPct / 100));
baseFee = this.roundMinor(baseFee + gasSurcharge);
}

const totalFee = processingFee + gasSurcharge;
const netAmount = input.amount - totalFee;
const totalFee = this.roundMinor(processingFee + gasSurcharge);
const netAmount = this.roundMinor(input.amount - totalFee);

const scheduleInfo: FeeScheduleInfo = {
id: schedule.id,
Expand Down
29 changes: 21 additions & 8 deletions backend/src/services/payouts/payout-batcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface ScheduledPayout {
anomalyFlagIds?: string[];
createdAt: Date;
updatedAt: Date;
/** Optimistic locking version - incremented on each status change */
version: number;
}

export interface ProviderBatchItem {
Expand Down Expand Up @@ -277,6 +279,7 @@ export class PayoutBatcher {
attempts: 0,
createdAt,
updatedAt: createdAt,
version: 1,
};
this.payouts.set(payout.id, payout);

Expand Down Expand Up @@ -311,7 +314,8 @@ export class PayoutBatcher {
cancel(payoutId: string, actorId = 'system'): boolean {
const payout = this.payouts.get(payoutId);
if (!payout || (payout.status !== 'scheduled' && payout.status !== 'held')) return false;
this.setStatus(payout, 'cancelled');
const expectedVersion = payout.version;
this.setStatus(payout, 'cancelled', expectedVersion);
this.audit(actorId, 'payout.cancelled', payout);
return true;
}
Expand Down Expand Up @@ -416,7 +420,8 @@ export class PayoutBatcher {
item.attempts += 1;
item.batchId = batch.id;
delete item.lastError;
this.setStatus(item, 'batched');
const expectedVersion = item.version;
this.setStatus(item, 'batched', expectedVersion);
}

try {
Expand All @@ -443,7 +448,8 @@ export class PayoutBatcher {
batch.completedAt = this.now();
for (const item of items) {
item.transactionHash = result.transactionHash;
this.setStatus(item, 'paid');
const expectedVersion = item.version;
this.setStatus(item, 'paid', expectedVersion);
this.audit('system', 'payout.settled', item, { batchId: batch.id, transactionHash: result.transactionHash });
}
this.auditBatch('payout_batch.settled', batch, { transactionHash: result.transactionHash });
Expand All @@ -454,13 +460,14 @@ export class PayoutBatcher {
batch.completedAt = this.now();
for (const item of items) {
item.lastError = message;
const expectedVersion = item.version;
if (item.attempts >= this.maxAttempts) {
this.setStatus(item, 'failed');
this.setStatus(item, 'failed', expectedVersion);
this.audit('system', 'payout.failed', item, { batchId: batch.id, error: message, final: true });
} else {
// Back to the queue on its own: the retry may land in a different batch.
item.scheduledFor = new Date(this.now().getTime() + this.retryDelayMs);
this.setStatus(item, 'scheduled');
this.setStatus(item, 'scheduled', expectedVersion);
this.audit('system', 'payout.failed', item, { batchId: batch.id, error: message, final: false });
}
}
Expand All @@ -474,15 +481,16 @@ export class PayoutBatcher {
const payout = this.payouts.get(flag.reference);
if (!payout || payout.status !== 'held') return;

const expectedVersion = payout.version;
if (flag.status === 'confirmed') {
this.setStatus(payout, 'cancelled');
this.setStatus(payout, 'cancelled', expectedVersion);
this.audit(flag.reviewedBy ?? 'system', 'payout.cancelled', payout, { anomalyFlagId: flag.id });
return;
}

// Released only once every flag on the payout has been cleared as a false positive.
if (this.anomalyDetector?.hasOpenFlags(payout.id)) return;
this.setStatus(payout, 'scheduled');
this.setStatus(payout, 'scheduled', expectedVersion);
this.audit(flag.reviewedBy ?? 'system', 'payout.scheduled', payout, {
releasedFromHold: true,
anomalyFlagIds: payout.anomalyFlagIds ?? [],
Expand Down Expand Up @@ -551,9 +559,14 @@ export class PayoutBatcher {
};
}

private setStatus(payout: ScheduledPayout, status: ScheduledPayoutStatus): void {
private setStatus(payout: ScheduledPayout, status: ScheduledPayoutStatus, expectedVersion?: number): void {
// Optimistic locking: reject if version doesn't match expected (race condition prevention)
if (expectedVersion !== undefined && payout.version !== expectedVersion) {
throw new Error('PAYOUT_VERSION_CONFLICT');
}
payout.status = status;
payout.updatedAt = this.now();
payout.version += 1;
}

private audit(
Expand Down
Loading