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
13 changes: 8 additions & 5 deletions src/api/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,8 @@ describe('AdminService', () => {
},
])
.mockResolvedValueOnce([
{ total_amount: '12.00' },
{ total_amount: '27.50' },
{ gross_amount: '12.00', total_amount: '15.96' },
{ gross_amount: '27.50', total_amount: '36.58' },
]);
topcoderChallengesService.getChallengeById.mockResolvedValue({
billing: {
Expand Down Expand Up @@ -487,7 +487,10 @@ describe('AdminService', () => {
},
});
expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, {
select: { total_amount: true },
select: {
gross_amount: true,
total_amount: true,
},
where: {
billing_account: '80001012',
currency: 'USD',
Expand Down Expand Up @@ -584,8 +587,8 @@ describe('AdminService', () => {
},
])
.mockResolvedValueOnce([
{ total_amount: '150.00' },
{ total_amount: '25.00' },
{ gross_amount: '150.00', total_amount: '199.50' },
{ gross_amount: '25.00', total_amount: '33.25' },
]);
topcoderChallengesService.getChallengeById.mockResolvedValue({
billing: {
Expand Down
21 changes: 16 additions & 5 deletions src/api/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
TopcoderChallengeInfo,
TopcoderChallengesService,
} from 'src/shared/topcoder/challenges.service';
import { resolveChallengeMemberPaymentAmount } from 'src/shared/payments/challenge-payment-amount.util';
import {
PaymentCycle,
WinningPaymentDetailsDto,
Expand Down Expand Up @@ -588,20 +589,25 @@ export class AdminService {
}

/**
* Sums non-cancelled USD payment rows for a challenge and billing account.
* Sums non-cancelled USD member-payment rows for a challenge and billing
* account.
*
* @param challengeId challenge external id stored on winnings.
* @param billingAccountId billing account stored on payment rows.
* @returns total member-payment amount that should remain locked or consumed
* for the challenge billing-account line item.
* @returns Total member-payment amount that should remain locked or consumed
* for the challenge billing-account line item. `gross_amount` is used before
* `total_amount` so fee-inclusive totals are not marked up again.
* @throws Prisma errors when the aggregate query fails.
*/
private async getActiveChallengePaymentTotal(
challengeId: string,
billingAccountId: number,
): Promise<number> {
const payments = await this.prisma.payment.findMany({
select: { total_amount: true },
select: {
gross_amount: true,
total_amount: true,
},
where: {
billing_account: String(billingAccountId),
currency: PrizeType.USD,
Expand All @@ -614,7 +620,12 @@ export class AdminService {
});
const totalAmount = payments.reduce(
(sum, paymentRow) =>
sum.plus(new Prisma.Decimal(paymentRow.total_amount ?? 0)),
sum.plus(
resolveChallengeMemberPaymentAmount({
grossAmount: paymentRow.gross_amount,
totalAmount: paymentRow.total_amount,
}),
),
new Prisma.Decimal(0),
);

Expand Down
18 changes: 12 additions & 6 deletions src/api/winnings/winnings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,8 @@ describe('WinningsService', () => {
status: 'COMPLETED',
});
tx.payment.findMany.mockResolvedValue([
{ total_amount: '75.00' },
{ total_amount: '25.00' },
{ gross_amount: '75.00', total_amount: '99.75' },
{ gross_amount: '25.00', total_amount: '33.25' },
]);

await service.createWinningWithPayments(
Expand All @@ -546,7 +546,10 @@ describe('WinningsService', () => {
);

expect(tx.payment.findMany).toHaveBeenCalledWith({
select: { total_amount: true },
select: {
gross_amount: true,
total_amount: true,
},
where: {
billing_account: '80001012',
currency: PrizeType.USD,
Expand Down Expand Up @@ -615,8 +618,8 @@ describe('WinningsService', () => {
status: 'DRAFT',
});
tx.payment.findMany.mockResolvedValue([
{ total_amount: '100.00' },
{ total_amount: '50.25' },
{ gross_amount: '100.00', total_amount: '120.00' },
{ gross_amount: '50.25', total_amount: '60.30' },
]);

await service.createWinningWithPayments(
Expand Down Expand Up @@ -645,7 +648,10 @@ describe('WinningsService', () => {
'challenge-id',
);
expect(tx.payment.findMany).toHaveBeenCalledWith({
select: { total_amount: true },
select: {
gross_amount: true,
total_amount: true,
},
where: {
billing_account: '80001012',
currency: PrizeType.USD,
Expand Down
21 changes: 15 additions & 6 deletions src/api/winnings/winnings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
TopcoderChallengesService,
} from 'src/shared/topcoder/challenges.service';
import { TopcoderM2MHttpError } from 'src/shared/topcoder/topcoder-m2m.service';
import { resolveChallengeMemberPaymentAmount } from 'src/shared/payments/challenge-payment-amount.util';

const BUDGET_LEDGER_DECIMAL_PLACES = 4;
const PAYMENT_DECIMAL_PLACES = 2;
Expand Down Expand Up @@ -556,22 +557,25 @@ export class WinningsService {
}

/**
* Sums non-cancelled persisted USD payment rows for a challenge and billing
* account.
* Sums non-cancelled persisted USD member-payment rows for a challenge and
* billing account.
*
* @param tx active Prisma transaction.
* @param challengeId challenge external id stored on winnings.
* @param billingAccountId billing account stored on payment rows.
* @returns Payment-scale total USD amount for active challenge payments on
* that billing account.
* @returns Payment-scale member-payment amount for active challenge payments
* on that billing account.
*/
private async getPersistedChallengePaymentTotal(
tx: Prisma.TransactionClient,
challengeId: string,
billingAccountId: number,
): Promise<number> {
const payments = await tx.payment.findMany({
select: { total_amount: true },
select: {
gross_amount: true,
total_amount: true,
},
where: {
billing_account: String(billingAccountId),
currency: PrizeType.USD,
Expand All @@ -584,7 +588,12 @@ export class WinningsService {
});
const totalAmount = payments.reduce(
(sum, paymentRow) =>
sum.plus(new Prisma.Decimal(paymentRow.total_amount ?? 0)),
sum.plus(
resolveChallengeMemberPaymentAmount({
grossAmount: paymentRow.gross_amount,
totalAmount: paymentRow.total_amount,
}),
),
new Prisma.Decimal(0),
);

Expand Down
50 changes: 50 additions & 0 deletions src/shared/payments/challenge-payment-amount.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Prisma } from '@prisma/client';

type PaymentAmountValue = Prisma.Decimal | number | string | null | undefined;

export interface ChallengePaymentBudgetAmount {
grossAmount?: PaymentAmountValue;
totalAmount?: PaymentAmountValue;
}

/**
* Converts a persisted payment amount to a Decimal when it is finite.
*
* @param value Raw amount from a finance payment column.
* @returns Decimal amount, or `undefined` when the value is missing or invalid.
* @throws This helper does not throw for missing or non-finite values.
*/
function toDecimalAmount(
value: PaymentAmountValue,
): Prisma.Decimal | undefined {
if (value === null || value === undefined) {
return undefined;
}

const amount = Number(value);

return Number.isFinite(amount) ? new Prisma.Decimal(amount) : undefined;
}

/**
* Resolves the member-payment amount that should be used for challenge budget sync.
*
* Challenge payment `total_amount` values can include the billing challenge fee
* for older or external callers, while `gross_amount` is the member-payment
* amount before markup. Billing-account lock/consume rows must be based on
* that member amount so the markup is not applied a second time. Rows without a
* valid gross amount fall back to `total_amount` for backward compatibility.
*
* @param payment payment amount fields from a finance payment row.
* @returns Decimal member-payment amount for billing-account synchronization.
* @throws This helper does not throw for invalid payment amounts.
*/
export function resolveChallengeMemberPaymentAmount(
payment: ChallengePaymentBudgetAmount,
): Prisma.Decimal {
return (
toDecimalAmount(payment.grossAmount) ??
toDecimalAmount(payment.totalAmount) ??
new Prisma.Decimal(0)
);
}
Loading