Skip to content

Commit e7419a8

Browse files
committed
PM-4958: Release engagement BA consumption on cancellation
What was broken Cancelling an engagement payment from wallet-admin updated the finance payment status, but the engagement consumed row in the billing-account ledger stayed in place. The BA details modal still showed the cancelled engagement amount as consumed. Root cause (if identifiable) The previous PM-4958 fix only recalculated challenge payment budget rows. Engagement payments were explicitly excluded from that path, and their BA consumed rows are maintained separately from challenge budget rows. What was changed Added wallet-admin cancellation handling for engagement payments. After the finance status transaction succeeds, the admin service now recalculates the active non-cancelled engagement ledger amounts and asks the billing-account service to sync the matching consumed rows, deleting stale rows when cancelled payments should no longer reserve budget. The sync supports both the legacy challengeId BA ledger shape and the newer typed externalId/externalType shape. Any added/updated tests Added AdminService coverage for engagement payment cancellation triggering BA sync. Added BillingAccountsService coverage for deleting stale engagement consumed rows, syncing active typed rows, and syncing legacy aggregate rows.
1 parent a32dddf commit e7419a8

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)