Skip to content

Commit efd64ee

Browse files
authored
Merge pull request #175 from topcoder-platform/PM-4958-2
PM-4958: Release engagement BA consumption on cancellation
2 parents a32dddf + e7419a8 commit efd64ee

4 files changed

Lines changed: 521 additions & 0 deletions

File tree

src/api/admin/admin.service.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ describe('AdminService', () => {
7474
getBillingAccountById: jest.Mock;
7575
getBillingAccountsForUser: jest.Mock;
7676
lockConsumeAmount: jest.Mock;
77+
syncEngagementConsumeAmounts: jest.Mock;
7778
};
7879
let accessControlService: {
7980
verifyAccess: jest.Mock;
@@ -120,6 +121,7 @@ describe('AdminService', () => {
120121
.mockResolvedValue({ id: 80001012, markup: 0.1 }),
121122
getBillingAccountsForUser: jest.fn().mockResolvedValue(['80001012']),
122123
lockConsumeAmount: jest.fn().mockResolvedValue(undefined),
124+
syncEngagementConsumeAmounts: jest.fn().mockResolvedValue(undefined),
123125
};
124126
accessControlService = {
125127
verifyAccess: jest.fn().mockResolvedValue(undefined),
@@ -457,6 +459,62 @@ describe('AdminService', () => {
457459
});
458460
});
459461

462+
it('releases engagement billing-account rows when an engagement payment is cancelled', async () => {
463+
prisma.payment.findMany
464+
.mockResolvedValueOnce([
465+
{
466+
billing_account: '80001012',
467+
currency: 'USD',
468+
installment_number: 1,
469+
payment_id: 'payment-1',
470+
payment_status: PaymentStatus.OWED,
471+
release_date: new Date('2026-04-28T00:00:00.000Z'),
472+
version: 1,
473+
winnings: {
474+
category: 'ENGAGEMENT_PAYMENT',
475+
external_id: 'assignment-1',
476+
type: 'PAYMENT',
477+
},
478+
},
479+
])
480+
.mockResolvedValueOnce([]);
481+
482+
const result = await service.updateWinnings(
483+
{
484+
paymentId: 'payment-1',
485+
paymentStatus: PaymentStatus.CANCELLED,
486+
winningsId: 'winning-1',
487+
} as any,
488+
'admin-1',
489+
['Payment Admin'],
490+
);
491+
492+
expect(result.data).toBe('Successfully updated winnings');
493+
expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, {
494+
select: {
495+
challenge_fee: true,
496+
total_amount: true,
497+
},
498+
where: {
499+
billing_account: '80001012',
500+
currency: 'USD',
501+
payment_status: { not: PaymentStatus.CANCELLED },
502+
winnings: {
503+
category: 'ENGAGEMENT_PAYMENT',
504+
external_id: 'assignment-1',
505+
type: 'PAYMENT',
506+
},
507+
},
508+
orderBy: [{ created_at: 'asc' }, { payment_id: 'asc' }],
509+
});
510+
expect(baService.syncEngagementConsumeAmounts).toHaveBeenCalledWith({
511+
amounts: [],
512+
billingAccountId: 80001012,
513+
externalId: 'assignment-1',
514+
});
515+
expect(baService.lockConsumeAmount).not.toHaveBeenCalled();
516+
});
517+
460518
it('returns task details for task payment with projectId and approver', async () => {
461519
prisma.winnings.findFirst.mockResolvedValue({
462520
winning_id: 'winning-task',

src/api/admin/admin.service.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@ import {
3636
import { WinningPaymentDetailsDto } from './dto/payment-details.dto';
3737

3838
const PAYMENT_DECIMAL_PLACES = 2;
39+
const BUDGET_LEDGER_DECIMAL_PLACES = 4;
3940

4041
interface ChallengeBudgetSyncTarget {
4142
billingAccountId: number;
4243
challengeId: string;
4344
}
4445

46+
interface EngagementBudgetSyncTarget {
47+
assignmentId: string;
48+
billingAccountId: number;
49+
}
50+
4551
/**
4652
* The admin winning service.
4753
*/
@@ -394,6 +400,55 @@ export class AdminService {
394400
return [...targets.values()];
395401
}
396402

403+
/**
404+
* Finds engagement billing-account rows that need to be reconciled after a
405+
* wallet-admin payment status change.
406+
*
407+
* @param payments payment rows selected by the update request.
408+
* @returns unique engagement assignment and billing-account pairs touched by
409+
* USD engagement payments.
410+
* @throws This helper does not throw.
411+
*/
412+
private getEngagementBudgetSyncTargets(
413+
payments: Awaited<ReturnType<AdminService['getPaymentsByWinningsId']>>,
414+
): EngagementBudgetSyncTarget[] {
415+
const targets = new Map<string, EngagementBudgetSyncTarget>();
416+
417+
payments.forEach((payment) => {
418+
const assignmentId =
419+
typeof payment.winnings.external_id === 'string'
420+
? payment.winnings.external_id.trim()
421+
: '';
422+
423+
if (
424+
!assignmentId ||
425+
payment.winnings.type !== winnings_type.PAYMENT ||
426+
payment.winnings.category !== winnings_category.ENGAGEMENT_PAYMENT ||
427+
(payment.currency ?? '') !== 'USD'
428+
) {
429+
return;
430+
}
431+
432+
const billingAccountId = this.normalizeBillingAccountId(
433+
payment.billing_account,
434+
);
435+
436+
if (!billingAccountId) {
437+
this.logger.warn(
438+
`Skipping engagement budget sync for payment ${payment.payment_id}; invalid billing account ${String(payment.billing_account)}`,
439+
);
440+
return;
441+
}
442+
443+
targets.set(`${assignmentId}:${billingAccountId}`, {
444+
assignmentId,
445+
billingAccountId,
446+
});
447+
});
448+
449+
return [...targets.values()];
450+
}
451+
397452
/**
398453
* Resolves the billing account to synchronize for a challenge.
399454
*
@@ -465,6 +520,25 @@ export class AdminService {
465520
);
466521
}
467522

523+
/**
524+
* Quantizes a billing-account ledger amount to the same four-decimal scale
525+
* used by billing-accounts-api-v6.
526+
*
527+
* @param amount decimal amount to normalize.
528+
* @returns JavaScript number rounded to four decimal places.
529+
* @throws This helper does not throw.
530+
*/
531+
private toBillingLedgerAmount(amount: Prisma.Decimal): number {
532+
return Number(
533+
amount
534+
.toDecimalPlaces(
535+
BUDGET_LEDGER_DECIMAL_PLACES,
536+
Prisma.Decimal.ROUND_HALF_UP,
537+
)
538+
.toFixed(BUDGET_LEDGER_DECIMAL_PLACES),
539+
);
540+
}
541+
468542
/**
469543
* Sums non-cancelled USD payment rows for a challenge and billing account.
470544
*
@@ -547,6 +621,74 @@ export class AdminService {
547621
);
548622
}
549623

624+
/**
625+
* Reads active engagement payment ledger amounts for one assignment.
626+
*
627+
* @param assignmentId engagement assignment external id stored on winnings.
628+
* @param billingAccountId billing account stored on payment rows.
629+
* @returns ledger-scale consumed amounts for non-cancelled finance payments,
630+
* in the same order used when engagement consumed rows were created.
631+
* @throws Prisma errors when the payment query fails.
632+
*/
633+
private async getActiveEngagementConsumeAmounts(
634+
assignmentId: string,
635+
billingAccountId: number,
636+
): Promise<number[]> {
637+
const payments = await this.prisma.payment.findMany({
638+
select: {
639+
challenge_fee: true,
640+
total_amount: true,
641+
},
642+
where: {
643+
billing_account: String(billingAccountId),
644+
currency: PrizeType.USD,
645+
payment_status: { not: payment_status.CANCELLED },
646+
winnings: {
647+
category: winnings_category.ENGAGEMENT_PAYMENT,
648+
external_id: assignmentId,
649+
type: winnings_type.PAYMENT,
650+
},
651+
},
652+
orderBy: [{ created_at: 'asc' }, { payment_id: 'asc' }],
653+
});
654+
655+
return payments.map((paymentRow) =>
656+
this.toBillingLedgerAmount(
657+
new Prisma.Decimal(paymentRow.total_amount ?? 0).plus(
658+
new Prisma.Decimal(paymentRow.challenge_fee ?? 0),
659+
),
660+
),
661+
);
662+
}
663+
664+
/**
665+
* Reconciles engagement billing-account consumed rows after a wallet-admin
666+
* cancellation changes the active payment set.
667+
*
668+
* @param targets unique engagement assignment and billing-account pairs to
669+
* synchronize.
670+
* @returns promise resolved after every target has been reconciled in BA.
671+
* @throws Error when BA synchronization fails.
672+
*/
673+
private async syncEngagementBudgetTargets(
674+
targets: EngagementBudgetSyncTarget[],
675+
): Promise<void> {
676+
await Promise.all(
677+
targets.map(async (target) => {
678+
const amounts = await this.getActiveEngagementConsumeAmounts(
679+
target.assignmentId,
680+
target.billingAccountId,
681+
);
682+
683+
await this.baService.syncEngagementConsumeAmounts({
684+
amounts,
685+
billingAccountId: target.billingAccountId,
686+
externalId: target.assignmentId,
687+
});
688+
}),
689+
);
690+
}
691+
550692
/**
551693
* Verify that a BA admin user has access to the billing account(s)
552694
* associated with the given winningsId. Throws BadRequestException when
@@ -642,6 +784,10 @@ export class AdminService {
642784
body.paymentStatus === PaymentStatus.CANCELLED
643785
? this.getChallengeBudgetSyncTargets(payments)
644786
: [];
787+
const engagementBudgetSyncTargets =
788+
body.paymentStatus === PaymentStatus.CANCELLED
789+
? this.getEngagementBudgetSyncTargets(payments)
790+
: [];
645791

646792
// iterate payments and build transaction list
647793
payments.forEach((payment) => {
@@ -928,6 +1074,10 @@ export class AdminService {
9281074
await this.syncChallengeBudgetTargets(challengeBudgetSyncTargets);
9291075
}
9301076

1077+
if (engagementBudgetSyncTargets.length > 0) {
1078+
await this.syncEngagementBudgetTargets(engagementBudgetSyncTargets);
1079+
}
1080+
9311081
if (needsReconciliation) {
9321082
const winning = await this.prisma.winnings.findFirst({
9331083
select: {

src/shared/topcoder/billing-accounts.service.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,34 @@ jest.mock('src/shared/global', () => ({
1717
},
1818
}));
1919

20+
const mockBaTx = {
21+
$executeRawUnsafe: jest.fn(),
22+
$queryRawUnsafe: jest.fn(),
23+
};
24+
const mockBaClient = {
25+
$transaction: jest.fn(),
26+
};
27+
28+
jest.mock('src/shared/global/ba-prisma.client', () => ({
29+
getBaClient: jest.fn(() => mockBaClient),
30+
}));
31+
2032
import { ChallengeStatuses } from 'src/dto/challenge.dto';
2133
import { BillingAccountsService } from './billing-accounts.service';
2234

2335
describe('BillingAccountsService', () => {
2436
let service: BillingAccountsService;
2537

2638
beforeEach(() => {
39+
jest.clearAllMocks();
40+
mockBaTx.$executeRawUnsafe.mockResolvedValue(undefined);
41+
mockBaTx.$queryRawUnsafe
42+
.mockResolvedValueOnce([{ exists: true }])
43+
.mockResolvedValueOnce([]);
44+
mockBaClient.$transaction.mockImplementation(
45+
(callback: (tx: typeof mockBaTx) => unknown) => callback(mockBaTx),
46+
);
47+
2748
service = new BillingAccountsService({} as any);
2849
});
2950

@@ -95,4 +116,81 @@ describe('BillingAccountsService', () => {
95116
});
96117
expect(consumeAmountSpy).not.toHaveBeenCalled();
97118
});
119+
120+
it('deletes stale engagement consumed rows when no active amounts remain', async () => {
121+
mockBaTx.$queryRawUnsafe
122+
.mockReset()
123+
.mockResolvedValueOnce([{ exists: true }])
124+
.mockResolvedValueOnce([{ id: 'consumed-row-1' }]);
125+
126+
await service.syncEngagementConsumeAmounts({
127+
amounts: [],
128+
billingAccountId: 80001012,
129+
externalId: 'assignment-1',
130+
});
131+
132+
expect(mockBaTx.$queryRawUnsafe).toHaveBeenNthCalledWith(
133+
2,
134+
expect.stringContaining('"externalId" = $2'),
135+
80001012,
136+
'assignment-1',
137+
);
138+
expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith(
139+
expect.stringContaining('DELETE FROM "ConsumedAmount"'),
140+
'consumed-row-1',
141+
);
142+
});
143+
144+
it('syncs engagement consumed rows to the active ledger amounts', async () => {
145+
mockBaTx.$queryRawUnsafe
146+
.mockReset()
147+
.mockResolvedValueOnce([{ exists: true }])
148+
.mockResolvedValueOnce([{ id: 'consumed-row-1' }, { id: 'stale-row-1' }]);
149+
150+
await service.syncEngagementConsumeAmounts({
151+
amounts: [24.2, 12],
152+
billingAccountId: 80001012,
153+
externalId: 'assignment-1',
154+
});
155+
156+
expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith(
157+
expect.stringContaining('UPDATE "ConsumedAmount"'),
158+
24.2,
159+
'consumed-row-1',
160+
);
161+
expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith(
162+
expect.stringContaining('UPDATE "ConsumedAmount"'),
163+
12,
164+
'stale-row-1',
165+
);
166+
expect(mockBaTx.$executeRawUnsafe).not.toHaveBeenCalledWith(
167+
expect.stringContaining('DELETE FROM "ConsumedAmount"'),
168+
expect.any(String),
169+
);
170+
});
171+
172+
it('syncs legacy consumed rows with the aggregate active amount', async () => {
173+
mockBaTx.$queryRawUnsafe
174+
.mockReset()
175+
.mockResolvedValueOnce([{ exists: false }])
176+
.mockResolvedValueOnce([{ id: 'legacy-row-1' }]);
177+
178+
await service.syncEngagementConsumeAmounts({
179+
amounts: [24.2, 12],
180+
billingAccountId: 80001012,
181+
externalId: 'assignment-1',
182+
});
183+
184+
expect(mockBaTx.$queryRawUnsafe).toHaveBeenNthCalledWith(
185+
2,
186+
expect.stringContaining('"challengeId" = $2'),
187+
80001012,
188+
'assignment-1',
189+
);
190+
expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith(
191+
expect.stringContaining('UPDATE "ConsumedAmount"'),
192+
36.2,
193+
'legacy-row-1',
194+
);
195+
});
98196
});

0 commit comments

Comments
 (0)