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
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x

WORKDIR /app
COPY . .
RUN npm install pnpm -g
RUN pnpm install
RUN npm install pnpm@9.15.9 -g
RUN pnpm install --frozen-lockfile --prod=false
RUN pnpm run build
RUN chmod +x appStartUp.sh
CMD ./appStartUp.sh
CMD ["./appStartUp.sh"]
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "",
"author": "",
"private": true,
"packageManager": "pnpm@9.15.9",
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
Expand Down
152 changes: 152 additions & 0 deletions src/api/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,158 @@ describe('AdminService', () => {
expect(baService.lockConsumeAmount).not.toHaveBeenCalled();
});

it('recalculates the challenge billing-account line item when a challenge payment amount is adjusted', async () => {
prisma.payment.findMany
.mockResolvedValueOnce([
{
billing_account: '80001012',
currency: 'USD',
installment_number: 1,
payment_id: 'payment-1',
payment_status: PaymentStatus.OWED,
release_date: new Date('2026-04-27T00:00:00.000Z'),
total_amount: '100.00',
version: 1,
winnings: {
category: 'CONTEST_PAYMENT',
description: 'Challenge payment',
external_id: 'challenge-1',
type: 'PAYMENT',
},
},
])
.mockResolvedValueOnce([
{ total_amount: '150.00' },
{ total_amount: '25.00' },
]);
topcoderChallengesService.getChallengeById.mockResolvedValue({
billing: {
billingAccountId: '80001012',
markup: 0.1,
},
id: 'challenge-1',
status: 'COMPLETED',
});

const result = await service.updateWinnings(
{
paymentAmount: 150,
paymentId: 'payment-1',
winningsId: 'winning-1',
} as any,
'admin-1',
['Payment Admin'],
);

expect(result.data).toBe('Successfully updated winnings');
expect(prisma.payment.update).toHaveBeenCalledWith({
where: {
payment_id: 'payment-1',
winnings_id: 'winning-1',
version: 1,
payment_status: {
in: [
PaymentStatus.CREDITED,
PaymentStatus.OWED,
PaymentStatus.ON_HOLD,
PaymentStatus.ON_HOLD_ADMIN,
PaymentStatus.PAID,
PaymentStatus.PROCESSING,
],
},
},
data: {
challenge_fee: undefined,
gross_amount: 150,
net_amount: 150,
total_amount: 150,
updated_at: expect.any(Date),
updated_by: 'admin-1',
version: 2,
},
});
expect(baService.lockConsumeAmount).toHaveBeenCalledWith({
billingAccountId: 80001012,
challengeId: 'challenge-1',
markup: 0.1,
status: 'COMPLETED',
totalPrizesInCents: 17500,
});
});

it('updates engagement challenge fee and consumed rows when an engagement payment amount is adjusted', async () => {
prisma.payment.findMany
.mockResolvedValueOnce([
{
billing_account: '80001012',
challenge_markup: '0.20',
currency: 'USD',
installment_number: 1,
payment_id: 'payment-1',
payment_status: PaymentStatus.OWED,
release_date: new Date('2026-04-28T00:00:00.000Z'),
total_amount: '100.00',
version: 1,
winnings: {
category: 'ENGAGEMENT_PAYMENT',
description: 'Engagement payment',
external_id: 'assignment-1',
type: 'PAYMENT',
},
},
])
.mockResolvedValueOnce([
{
challenge_fee: '30.00',
total_amount: '150.00',
},
]);

const result = await service.updateWinnings(
{
paymentAmount: 150,
paymentId: 'payment-1',
winningsId: 'winning-1',
} as any,
'admin-1',
['Payment Admin'],
);

expect(result.data).toBe('Successfully updated winnings');
expect(prisma.payment.update).toHaveBeenCalledWith({
where: {
payment_id: 'payment-1',
winnings_id: 'winning-1',
version: 1,
payment_status: {
in: [
PaymentStatus.CREDITED,
PaymentStatus.OWED,
PaymentStatus.ON_HOLD,
PaymentStatus.ON_HOLD_ADMIN,
PaymentStatus.PAID,
PaymentStatus.PROCESSING,
],
},
},
data: {
challenge_fee: 30,
gross_amount: 150,
net_amount: 150,
total_amount: 150,
updated_at: expect.any(Date),
updated_by: 'admin-1',
version: 2,
},
});
expect(baService.syncEngagementConsumeAmounts).toHaveBeenCalledWith({
amounts: [180],
billingAccountId: 80001012,
externalId: 'assignment-1',
});
expect(baService.lockConsumeAmount).not.toHaveBeenCalled();
});

it('returns task details for task payment with projectId and approver', async () => {
prisma.winnings.findFirst.mockResolvedValue({
winning_id: 'winning-task',
Expand Down
76 changes: 63 additions & 13 deletions src/api/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export class AdminService {

/**
* Finds challenge billing-account rows that need to be recalculated after a
* wallet-admin payment status change.
* wallet-admin payment status or amount change.
*
* @param payments payment rows selected by the update request.
* @returns unique challenge and billing-account pairs touched by USD
Expand Down Expand Up @@ -402,7 +402,7 @@ export class AdminService {

/**
* Finds engagement billing-account rows that need to be reconciled after a
* wallet-admin payment status change.
* wallet-admin payment status or amount change.
*
* @param payments payment rows selected by the update request.
* @returns unique engagement assignment and billing-account pairs touched by
Expand Down Expand Up @@ -539,6 +539,39 @@ export class AdminService {
);
}

/**
* Recomputes the persisted engagement billing fee after an admin amount edit.
*
* Engagement consumed rows store payment total plus `payment.challenge_fee`.
* When wallet admin adjusts the payment total, the fee must be recalculated
* from the persisted markup before finance resyncs the BA consumed row.
*
* @param category winning category for the payment being edited.
* @param challengeMarkup markup persisted on the payment row.
* @param totalAmount new payment total amount.
* @returns recalculated fee for engagement payments, or `undefined` when the
* payment is not an engagement payment or has no valid persisted markup.
* @throws This helper does not throw.
*/
private calculateAdjustedChallengeFee(
category: winnings_category | null,
challengeMarkup: Prisma.Decimal | number | string | null,
totalAmount: number,
): number | undefined {
if (category !== winnings_category.ENGAGEMENT_PAYMENT) {
return undefined;
}

const markup = Number(challengeMarkup);
if (!Number.isFinite(markup) || markup < 0) {
return undefined;
}

return this.toPaymentAmount(
new Prisma.Decimal(totalAmount).mul(new Prisma.Decimal(markup)),
);
}

/**
* Sums non-cancelled USD payment rows for a challenge and billing account.
*
Expand Down Expand Up @@ -574,8 +607,8 @@ export class AdminService {
}

/**
* Rewrites challenge billing-account budget rows after a wallet-admin status
* update changes the active payment total.
* Rewrites challenge billing-account budget rows after a wallet-admin update
* changes the active payment total.
*
* @param targets unique challenge and billing-account pairs to synchronize.
* @returns promise resolved after every target has been sent to BA.
Expand Down Expand Up @@ -663,7 +696,7 @@ export class AdminService {

/**
* Reconciles engagement billing-account consumed rows after a wallet-admin
* cancellation changes the active payment set.
* update changes the active payment amounts or active payment set.
*
* @param targets unique engagement assignment and billing-account pairs to
* synchronize.
Expand Down Expand Up @@ -780,14 +813,15 @@ export class AdminService {
tx: Prisma.TransactionClient,
) => Promise<unknown>)[] = [];
const now = new Date().getTime();
const challengeBudgetSyncTargets =
body.paymentStatus === PaymentStatus.CANCELLED
? this.getChallengeBudgetSyncTargets(payments)
: [];
const engagementBudgetSyncTargets =
body.paymentStatus === PaymentStatus.CANCELLED
? this.getEngagementBudgetSyncTargets(payments)
: [];
const shouldSyncBudget =
body.paymentStatus === PaymentStatus.CANCELLED ||
body.paymentAmount !== undefined;
const challengeBudgetSyncTargets = shouldSyncBudget
? this.getChallengeBudgetSyncTargets(payments)
: [];
const engagementBudgetSyncTargets = shouldSyncBudget
? this.getEngagementBudgetSyncTargets(payments)
: [];

// iterate payments and build transaction list
payments.forEach((payment) => {
Expand Down Expand Up @@ -999,6 +1033,12 @@ export class AdminService {
) {
// ideally we should be maintaining the original split of the payment amount between installments - but we aren't really using splits anymore
if (payment.installment_number === 1) {
const challengeFee = this.calculateAdjustedChallengeFee(
payment.winnings.category,
payment.challenge_markup,
body.paymentAmount,
);

transactions.push((tx) =>
this.updatePaymentAmount(
userId,
Expand All @@ -1007,6 +1047,7 @@ export class AdminService {
body.paymentAmount,
body.paymentAmount,
body.paymentAmount,
challengeFee,
version,
tx,
),
Expand All @@ -1027,6 +1068,12 @@ export class AdminService {
`update amounts -> ${body.paymentAmount.toFixed(2)} (installment 1)`,
);
} else {
const challengeFee = this.calculateAdjustedChallengeFee(
payment.winnings.category,
payment.challenge_markup,
body.paymentAmount,
);

transactions.push((tx) =>
this.updatePaymentAmount(
userId,
Expand All @@ -1035,6 +1082,7 @@ export class AdminService {
0,
0,
body.paymentAmount,
challengeFee,
version,
tx,
),
Expand Down Expand Up @@ -1436,6 +1484,7 @@ export class AdminService {
netAmount: number,
grossAmount: number,
totalAmount: number,
challengeFee: number | undefined,
currentVersion: number,
tx?: Prisma.TransactionClient,
) {
Expand All @@ -1459,6 +1508,7 @@ export class AdminService {
net_amount: netAmount,
gross_amount: grossAmount,
total_amount: totalAmount,
challenge_fee: challengeFee,
updated_at: new Date(),
updated_by: userId,
version: currentVersion + 1,
Expand Down
2 changes: 1 addition & 1 deletion src/api/challenges/challenges.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export class ChallengesService {
title: challenge.name,
description: payment.description || challenge.name,
externalId: challenge.id,
...(payment.status ? { status: payment.status } : {}),
...(paymentStatus ? { status: paymentStatus } : {}),
details: [
{
totalAmount: payment.amount,
Expand Down
Loading