diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index a03ab99..e0c5c33 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -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: { @@ -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', @@ -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: { diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 7ab1791..348e0db 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -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, @@ -588,12 +589,14 @@ 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( @@ -601,7 +604,10 @@ export class AdminService { billingAccountId: number, ): Promise { 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, @@ -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), ); diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 2c75172..d561500 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -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( @@ -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, @@ -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( @@ -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, diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index a4ae53c..475abb1 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -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; @@ -556,14 +557,14 @@ 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, @@ -571,7 +572,10 @@ export class WinningsService { billingAccountId: number, ): Promise { 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, @@ -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), ); diff --git a/src/shared/payments/challenge-payment-amount.util.ts b/src/shared/payments/challenge-payment-amount.util.ts new file mode 100644 index 0000000..db26b32 --- /dev/null +++ b/src/shared/payments/challenge-payment-amount.util.ts @@ -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) + ); +}