Skip to content

Commit cd3ec29

Browse files
fix(billing): convert cents to units in creditDisputeReinstated (#3625)
V8 audit C5: amountCents was passed directly to creditCompensation instead of being converted to meter units. Adds getDollarsToUnitRatio() conversion (default 1000 → 5000 cents = 50,000 units) + audit-trail logging of both amountCents and creditUnits for traceability.
1 parent 73e4062 commit cd3ec29

2 files changed

Lines changed: 29 additions & 10 deletions

File tree

modules/billing/services/billing.admin.service.js

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import ProcessedStripeEventRepository from '../repositories/billing.processedStr
99
import OrganizationRepository from '../../organizations/repositories/organizations.repository.js';
1010
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
1111
import BillingWebhookService from './billing.webhook.service.js';
12+
import { getDollarsToUnitRatio } from '../lib/billing.constants.js';
1213

1314
/**
1415
* Valid plan names from config (immutable set for O(1) lookups).
@@ -296,13 +297,12 @@ const cancelSubscription = async (orgId) => {
296297
* Idempotent: `refundRequestId` is used as the idempotency key so double-clicking
297298
* the admin UI never produces a double credit.
298299
*
299-
* Audit trail: logs adminUserId + chargeId + amountCents on every call.
300+
* Audit trail: logs adminUserId + chargeId + amountCents + creditUnits on every call.
300301
*
301302
* @param {string} chargeId - Stripe charge ID (ch_xxx) to correlate with the dispute.
302-
* @param {number} amountCents - Amount to credit in cents (positive integer). Converted to
303-
* meter units by a 1:1 mapping — callers should pass the
304-
* dispute amount or a proportion; the exact unit conversion
305-
* is owned by ops (they know the pack price per unit).
303+
* @param {number} amountCents - Dispute amount in cents (positive integer). Internally converted
304+
* to meter units via `config.billing.meter.dollarsToUnitRatio`
305+
* (default 1000 → $1 = 1000 units, so 5000 cents = 50,000 units).
306306
* @param {string} reason - Ops note for audit trail (stored in ledger entry refId context).
307307
* @param {string} refundRequestId - UUID per click (idempotency key).
308308
* @param {string} orgId - Organization ObjectId (string) — whose extras balance to credit.
@@ -338,14 +338,15 @@ const creditDisputeReinstated = async (chargeId, amountCents, reason, refundRequ
338338
// Idempotency key includes the refundRequestId to prevent double-click double-credit.
339339
const refId = `dispute-credit-${refundRequestId}`;
340340

341-
// Credit the extras balance using the compensation path.
342-
// amountCents is used directly as the credit unit — ops chooses the proportional amount.
343-
const result = await BillingExtraBalanceRepository.creditCompensation(orgId, amountCents, refId, reason);
341+
// Convert cents to meter units using the configured dollarsToUnitRatio.
342+
const creditUnits = Math.round((amountCents / 100) * getDollarsToUnitRatio());
343+
const result = await BillingExtraBalanceRepository.creditCompensation(orgId, creditUnits, refId, reason);
344344

345345
logger.info('[billing.admin] creditDisputeReinstated — applied', {
346346
orgId,
347347
chargeId,
348348
amountCents,
349+
creditUnits,
349350
reason,
350351
refundRequestId,
351352
adminUserId,

modules/billing/tests/billing.admin.service.unit.tests.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,17 +452,35 @@ describe('BillingAdminService unit tests:', () => {
452452
chargeId, amountCents, reason, refundRequestId, orgId, adminUserId,
453453
);
454454

455+
// amountCents=2000, default dollarsToUnitRatio=1000 → creditUnits = Math.round((2000/100)*1000) = 20000
456+
const expectedCreditUnits = Math.round((amountCents / 100) * 1000);
455457
expect(result.applied).toBe(true);
456458
expect(result.ledgerEntry).toBeDefined();
457459
expect(mockExtraBalanceRepository.creditCompensation).toHaveBeenCalledWith(
458460
orgId,
459-
amountCents,
461+
expectedCreditUnits,
460462
`dispute-credit-${refundRequestId}`,
461463
reason,
462464
);
463465
expect(mockLogger.info).toHaveBeenCalledWith(
464466
'[billing.admin] creditDisputeReinstated — applied',
465-
expect.objectContaining({ orgId, chargeId, amountCents, applied: true }),
467+
expect.objectContaining({ orgId, chargeId, amountCents, creditUnits: expectedCreditUnits, applied: true }),
468+
);
469+
});
470+
471+
test('converts amountCents to creditUnits using dollarsToUnitRatio (V8 audit C5)', async () => {
472+
mockConfig.billing.meter = { dollarsToUnitRatio: 1000 };
473+
474+
await BillingAdminService.creditDisputeReinstated(
475+
'ch_test', 5000, 'dispute', 'req_xxxxxxxx', orgId, adminUserId,
476+
);
477+
478+
// 5000 cents = $50 × 1000 units/dollar = 50,000 units
479+
expect(mockExtraBalanceRepository.creditCompensation).toHaveBeenCalledWith(
480+
orgId,
481+
50000,
482+
expect.any(String),
483+
'dispute',
466484
);
467485
});
468486

0 commit comments

Comments
 (0)