diff --git a/src/api/BillingAccount.ts b/src/api/BillingAccount.ts new file mode 100644 index 0000000..128bf98 --- /dev/null +++ b/src/api/BillingAccount.ts @@ -0,0 +1,147 @@ +import _ from "lodash"; +import axios from "axios"; +import { StatusBuilder } from "@grpc/grpc-js"; +import { Status } from "@grpc/grpc-js/build/src/constants"; + +import m2m from "../helpers/MachineToMachineToken"; +import { ChallengeStatuses, TGBillingAccounts } from "../common/Constants"; + +const { V3_BA_API_URL = "https://api.topcoder-dev.com/v3/billing-accounts" } = process.env; + +async function lockAmount(billingAccountId: number, dto: LockAmountDTO) { + console.log("BA validation lock amount:", billingAccountId, dto); + + try { + const m2mToken = await m2m.getM2MToken(); + + await axios.patch( + `${V3_BA_API_URL}/${billingAccountId}/lock-amount`, + { + param: dto, + }, + { + headers: { + Authorization: `Bearer ${m2mToken}`, + "Content-Type": "application/json", + }, + } + ); + } catch (err: any) { + throw new StatusBuilder() + .withCode(Status.INTERNAL) + .withDetails(err.response?.data?.result?.content ?? "Failed to lock challenge amount") + .build(); + } +} + +async function consumeAmount(billingAccountId: number, dto: ConsumeAmountDTO) { + console.log("BA validation consume amount:", billingAccountId, dto); + + try { + const m2mToken = await m2m.getM2MToken(); + + await axios.patch( + `${V3_BA_API_URL}/${billingAccountId}/consume-amount`, + { + param: dto, + }, + { + headers: { + Authorization: `Bearer ${m2mToken}`, + "Content-Type": "application/json", + }, + } + ); + } catch (err: any) { + throw new StatusBuilder() + .withCode(Status.INTERNAL) + .withDetails(err.response?.data?.result?.content ?? "Failed to consume challenge amount") + .build(); + } +} + +interface LockAmountDTO { + challengeId: string; + lockAmount: number; +} +interface ConsumeAmountDTO { + challengeId: string; + consumeAmount: number; + markup?: number; +} + +// prettier-ignore +export async function lockConsumeAmount(baValidation: BAValidation, rollback: boolean = false): Promise { + if (!_.isNumber(baValidation.billingAccountId)) { + console.warn("Challenge doesn't have billing account id:", baValidation); + return; + } + if (_.includes(TGBillingAccounts, baValidation.billingAccountId)) { + console.info("Ignore BA validation for Topgear account:", baValidation.billingAccountId); + return; + } + + console.log("BA validation:", baValidation); + + if ( + baValidation.status === ChallengeStatuses.New || + baValidation.status === ChallengeStatuses.Draft || + baValidation.status === ChallengeStatuses.Active || + baValidation.status === ChallengeStatuses.Approved + ) { + // Update lock amount + const currAmount = baValidation.totalPrizesInCents / 100; + const prevAmount = baValidation.prevTotalPrizesInCents / 100; + + if (currAmount !== prevAmount) { + await lockAmount(baValidation.billingAccountId, { + challengeId: baValidation.challengeId!, + lockAmount: rollback ? prevAmount : currAmount, + }); + } + } else if (baValidation.status === ChallengeStatuses.Completed) { + // Note an already completed challenge could still be updated with prizes + const currAmount = baValidation.totalPrizesInCents / 100; + const prevAmount = baValidation.prevStatus === ChallengeStatuses.Completed ? baValidation.prevTotalPrizesInCents / 100 : 0; + + if (currAmount !== prevAmount) { + await consumeAmount(baValidation.billingAccountId, { + challengeId: baValidation.challengeId!, + consumeAmount: rollback ? prevAmount : currAmount, + markup: baValidation.markup, + }); + } + } else if ( + baValidation.status === ChallengeStatuses.Deleted || + baValidation.status === ChallengeStatuses.Canceled || + baValidation.status === ChallengeStatuses.CancelledFailedReview || + baValidation.status === ChallengeStatuses.CancelledFailedScreening || + baValidation.status === ChallengeStatuses.CancelledZeroSubmissions || + baValidation.status === ChallengeStatuses.CancelledWinnerUnresponsive || + baValidation.status === ChallengeStatuses.CancelledClientRequest || + baValidation.status === ChallengeStatuses.CancelledRequirementsInfeasible || + baValidation.status === ChallengeStatuses.CancelledZeroRegistrations || + baValidation.status === ChallengeStatuses.CancelledPaymentFailed + ) { + // Challenge canceled, unlock previous locked amount + const currAmount = 0; + const prevAmount = baValidation.prevTotalPrizesInCents / 100; + + if (currAmount !== prevAmount) { + await lockAmount(baValidation.billingAccountId, { + challengeId: baValidation.challengeId!, + lockAmount: rollback ? prevAmount : 0, + }); + } + } +} + +export interface BAValidation { + challengeId?: string; + billingAccountId?: number; + markup?: number; + prevStatus?: string; + status?: string; + prevTotalPrizesInCents: number; + totalPrizesInCents: number; +} diff --git a/src/domain/Challenge.ts b/src/domain/Challenge.ts index 1d169f1..ffadb9d 100644 --- a/src/domain/Challenge.ts +++ b/src/domain/Challenge.ts @@ -27,9 +27,10 @@ import _ from "lodash"; import { ChallengeStatuses, ES_INDEX, ES_REFRESH, Topics } from "../common/Constants"; import BusApi from "../helpers/BusApi"; import ElasticSearch from "../helpers/ElasticSearch"; -import { ScanCriteria } from "../models/common/common"; +import { LookupCriteria, ScanCriteria } from "../models/common/common"; import legacyMapper from "../util/LegacyMapper"; import ChallengeScheduler from "../util/ChallengeScheduler"; +import { BAValidation, lockConsumeAmount } from "../api/BillingAccount"; if (!process.env.GRPC_ACL_SERVER_HOST || !process.env.GRPC_ACL_SERVER_PORT) { throw new Error("Missing required configurations GRPC_ACL_SERVER_HOST and GRPC_ACL_SERVER_PORT"); @@ -144,62 +145,80 @@ class ChallengeDomain extends CoreOperations { } } - const totalPrizes = this.calculateTotalPrizesInCents(input.prizeSets ?? []); + const totalPrizesInCents = this.calculateTotalPrizesInCents(input.prizeSets ?? []); const now = new Date().getTime(); + const challengeId = IdGenerator.generateUUID(); + + // Lock amount + const baValidation: BAValidation = { + billingAccountId: input.billing?.billingAccountId, + challengeId, + markup: input.billing?.markup, + status: input.status, + totalPrizesInCents, + prevTotalPrizesInCents: 0, + }; + await lockConsumeAmount(baValidation); - // Begin Anti-Corruption Layer - - // prettier-ignore - const { legacy, legacyChallengeId, phases } = await this.createLegacyChallenge(input, input.status, input.trackId, input.typeId, input.tags, metadata); - - // End Anti-Corruption Layer + let newChallenge: Challenge; + try { + // Begin Anti-Corruption Layer - // prettier-ignore - const challenge: Challenge = { - id: IdGenerator.generateUUID(), - created: now, - createdBy: handle, - updated: now, - updatedBy: handle, - winners: [], - payments: [], - overview: { - totalPrizes: totalPrizes / 100, - totalPrizesInCents: totalPrizes, - }, - ...input, - prizeSets: (input.prizeSets ?? []).map((prizeSet) => { - return { - ...prizeSet, - prizes: (prizeSet.prizes ?? []).map((prize) => { - return { - ...prize, - value: prize.amountInCents! / 100, - }; - }), - }; - }), - legacy, - phases, - legacyId: legacyChallengeId != null ? legacyChallengeId : undefined, - description: sanitize(input.description ?? "", input.descriptionFormat), - privateDescription: sanitize(input.privateDescription ?? "", input.descriptionFormat), - metadata: - input.metadata.map((m) => { - let parsedValue = m.value; - try { - parsedValue = JSON.parse(m.value); - } catch (e) { - // ignore error and use unparsed value - } + // prettier-ignore + const { legacy, legacyChallengeId, phases } = await this.createLegacyChallenge(input, input.status, input.trackId, input.typeId, input.tags, metadata); + + // End Anti-Corruption Layer + + const challenge: Challenge = { + id: challengeId, + created: now, + createdBy: handle, + updated: now, + updatedBy: handle, + winners: [], + payments: [], + overview: { + totalPrizes: totalPrizesInCents / 100, + totalPrizesInCents, + }, + ...input, + prizeSets: (input.prizeSets ?? []).map((prizeSet) => { return { - name: m.name, - value: parsedValue, + ...prizeSet, + prizes: (prizeSet.prizes ?? []).map((prize) => { + return { + ...prize, + value: prize.amountInCents! / 100, + }; + }), }; - }) ?? [], - }; + }), + legacy, + phases, + legacyId: legacyChallengeId != null ? legacyChallengeId : undefined, + description: sanitize(input.description ?? "", input.descriptionFormat), + privateDescription: sanitize(input.privateDescription ?? "", input.descriptionFormat), + metadata: + input.metadata.map((m) => { + let parsedValue = m.value; + try { + parsedValue = JSON.parse(m.value); + } catch (e) { + // ignore error and use unparsed value + } + return { + name: m.name, + value: parsedValue, + }; + }) ?? [], + }; - const newChallenge = await super.create(challenge, metadata); + newChallenge = await super.create(challenge, metadata); + } catch (err) { + // Rollback lock amount + await lockConsumeAmount(baValidation, true); + throw err; + } if (input.phases && input.phases.length && this.shouldUseScheduler(newChallenge)) { await ChallengeScheduler.schedule(newChallenge.id, input.phases); @@ -214,127 +233,148 @@ class ChallengeDomain extends CoreOperations { metadata: Metadata ): Promise { const { items } = await this.scan(scanCriteria, undefined); - let challenge = items[0] as Challenge; + const challenge = items[0] as Challenge; + let updatedChallenge; - // Begin Anti-Corruption Layer - let legacyId: number | null = null; - if (challenge.legacy!.pureV5Task !== true) { - if (input.status === ChallengeStatuses.Draft) { - if (items.length === 0 || items[0] == null) { - throw new StatusBuilder() - .withCode(Status.NOT_FOUND) - .withDetails("Challenge not found") - .build(); - } - // prettier-ignore - const createChallengeInput: CreateChallengeInput = { - name: input.name ?? challenge!.name, - typeId: input.typeId ?? challenge!.typeId, - trackId: input.trackId ?? challenge!.trackId, - billing: challenge.billing, - legacy: _.assign({}, challenge.legacy, input.legacy), - metadata: input.metadataUpdate != null ? input.metadataUpdate.metadata : challenge!.metadata, - phases: input.phaseUpdate != null ? input.phaseUpdate.phases : challenge!.phases, - events: input.eventUpdate != null ? input.eventUpdate.events : challenge!.events, - terms: input.termUpdate != null ? input.termUpdate.terms : challenge!.terms, - prizeSets: input.prizeSetUpdate != null ? input.prizeSetUpdate.prizeSets : challenge!.prizeSets, - tags: input.tagUpdate != null ? input.tagUpdate.tags : challenge!.tags, - status: input.status ?? challenge!.status, - attachments: input.attachmentUpdate != null ? input.attachmentUpdate.attachments : challenge!.attachments, - groups: input.groupUpdate != null ? input.groupUpdate.groups : challenge!.groups, - discussions: input.discussionUpdate != null ? input.discussionUpdate.discussions : challenge!.discussions, - }; + // prettier-ignore + const prevTotalPrizesInCents = this.calculateTotalPrizesInCents(challenge?.prizeSets ?? []); - // prettier-ignore - const { legacy, legacyChallengeId, phases } = await this.createLegacyChallenge(createChallengeInput, input.status, challenge!.trackId, challenge!.typeId, challenge!.tags, metadata, challenge.id); + // prettier-ignore + const totalPrizesInCents = _.isArray(input.prizeSetUpdate?.prizeSets) ? this.calculateTotalPrizesInCents(input.prizeSetUpdate?.prizeSets!) : prevTotalPrizesInCents; + + const baValidation: BAValidation = { + challengeId: challenge?.id, + billingAccountId: input.billing?.billingAccountId ?? challenge?.billing?.billingAccountId, + markup: + input.billing?.markup !== undefined && input.billing?.markup !== null + ? input.billing?.markup + : challenge?.billing?.markup, + status: input.status ?? challenge?.status, + prevStatus: challenge?.status, + totalPrizesInCents, + prevTotalPrizesInCents, + }; - input.legacy = legacy; - input.phaseUpdate = { phases }; - legacyId = legacyChallengeId; - } else if (challenge.status !== ChallengeStatuses.New) { - // prettier-ignore - const updateChallengeInput = await legacyMapper.mapChallengeUpdateInput( - challenge.legacyId!, - challenge.legacy?.subTrack!, - challenge.billing?.billingAccountId, - input - ); - - if ( - updateChallengeInput.termUpdate || - updateChallengeInput.groupUpdate || - updateChallengeInput.phaseUpdate || - updateChallengeInput.prizeUpdate || - updateChallengeInput.projectStatusId || - !_.isEmpty(updateChallengeInput.name) || - !_.isEmpty(updateChallengeInput.projectInfo) - ) { - const { updatedCount } = await legacyChallengeDomain.update( - updateChallengeInput, - metadata - ); - if (updatedCount === 0) { + await lockConsumeAmount(baValidation); + try { + // Begin Anti-Corruption Layer + let legacyId: number | null = null; + if (challenge.legacy!.pureV5Task !== true) { + if (input.status === ChallengeStatuses.Draft) { + if (items.length === 0 || items[0] == null) { throw new StatusBuilder() - .withCode(Status.ABORTED) - .withDetails("Failed to update challenge") + .withCode(Status.NOT_FOUND) + .withDetails("Challenge not found") .build(); } + // prettier-ignore + const createChallengeInput: CreateChallengeInput = { + name: input.name ?? challenge!.name, + typeId: input.typeId ?? challenge!.typeId, + trackId: input.trackId ?? challenge!.trackId, + billing: challenge.billing, + legacy: _.assign({}, challenge.legacy, input.legacy), + metadata: input.metadataUpdate != null ? input.metadataUpdate.metadata : challenge!.metadata, + phases: input.phaseUpdate != null ? input.phaseUpdate.phases : challenge!.phases, + events: input.eventUpdate != null ? input.eventUpdate.events : challenge!.events, + terms: input.termUpdate != null ? input.termUpdate.terms : challenge!.terms, + prizeSets: input.prizeSetUpdate != null ? input.prizeSetUpdate.prizeSets : challenge!.prizeSets, + tags: input.tagUpdate != null ? input.tagUpdate.tags : challenge!.tags, + status: input.status ?? challenge!.status, + attachments: input.attachmentUpdate != null ? input.attachmentUpdate.attachments : challenge!.attachments, + groups: input.groupUpdate != null ? input.groupUpdate.groups : challenge!.groups, + discussions: input.discussionUpdate != null ? input.discussionUpdate.discussions : challenge!.discussions, + }; + + // prettier-ignore + const { legacy, legacyChallengeId, phases } = await this.createLegacyChallenge(createChallengeInput, input.status, challenge!.trackId, challenge!.typeId, challenge!.tags, metadata); + + input.legacy = legacy; + input.phaseUpdate = { phases }; + legacyId = legacyChallengeId; + } else if (challenge.status !== ChallengeStatuses.New) { + // prettier-ignore + const updateChallengeInput = await legacyMapper.mapChallengeUpdateInput( + challenge.legacyId!, + challenge.legacy?.subTrack!, + challenge.billing?.billingAccountId, + input + ); + + if ( + updateChallengeInput.termUpdate || + updateChallengeInput.groupUpdate || + updateChallengeInput.phaseUpdate || + updateChallengeInput.prizeUpdate || + updateChallengeInput.projectStatusId || + !_.isEmpty(updateChallengeInput.name) || + !_.isEmpty(updateChallengeInput.projectInfo) + ) { + const { updatedCount } = await legacyChallengeDomain.update( + updateChallengeInput, + metadata + ); + if (updatedCount === 0) { + throw new StatusBuilder() + .withCode(Status.ABORTED) + .withDetails("Failed to update challenge") + .build(); + } + } } } - } - // End Anti-Corruption Layer + // End Anti-Corruption Layer - // prettier-ignore - const totalPrizesInCents = this.calculateTotalPrizesInCents(input.prizeSetUpdate?.prizeSets ?? challenge.prizeSets ?? []); - - const updatedChallenge = await super.update( - scanCriteria, - // prettier-ignore - { - name: input.name != null ? sanitize(input.name) : undefined, - typeId: input.typeId != null ? input.typeId : undefined, - trackId: input.trackId != null ? input.trackId : undefined, - timelineTemplateId: input.timelineTemplateId != null ? input.timelineTemplateId : undefined, - legacy: input.legacy != null ? input.legacy : undefined, - billing: input.billing != null ? input.billing : undefined, - description: input.description != null ? sanitize(input.description, input.descriptionFormat ?? challenge.descriptionFormat) : undefined, - privateDescription: input.privateDescription != null ? sanitize(input.privateDescription, input.descriptionFormat ?? challenge.descriptionFormat) : undefined, - descriptionFormat: input.descriptionFormat != null ? input.descriptionFormat : undefined, - task: input.task != null ? input.task : undefined, - winners: input.winnerUpdate != null ? input.winnerUpdate.winners : undefined, - payments: input.paymentUpdate != null ? input.paymentUpdate.payments : undefined, - discussions: input.discussionUpdate != null ? input.discussionUpdate.discussions : undefined, - metadata: input.metadataUpdate != null ? input.metadataUpdate.metadata : undefined, - phases: input.phaseUpdate != null ? input.phaseUpdate.phases : undefined, - events: input.eventUpdate != null ? input.eventUpdate.events : undefined, - terms: input.termUpdate != null ? input.termUpdate.terms : undefined, - prizeSets: input.prizeSetUpdate != null ? input.prizeSetUpdate.prizeSets.map((prizeSet) => { - return { - ...prizeSet, - prizes: (prizeSet.prizes ?? []).map((prize) => { - return { - ...prize, - value: prize.amountInCents! / 100, - }; - }), - }; - }) : undefined, - tags: input.tagUpdate != null ? input.tagUpdate.tags : undefined, - status: input.status != null ? input.status : undefined, - attachments: input.attachmentUpdate != null ? input.attachmentUpdate.attachments : undefined, - groups: input.groupUpdate != null ? input.groupUpdate.groups : undefined, - projectId: input.projectId != null ? input.projectId : undefined, - startDate: input.startDate != null ? input.startDate : undefined, - endDate: input.endDate != null ? input.endDate : undefined, - overview: input.overview != null ? { - totalPrizes: totalPrizesInCents / 100, - totalPrizesInCents, - } : undefined, - legacyId: legacyId != null ? legacyId : undefined, - constraints: input.constraints != null ? input.constraints : undefined, - }, - metadata - ); + updatedChallenge = await super.update( + scanCriteria, + // prettier-ignore + { + name: input.name != null ? sanitize(input.name) : undefined, + typeId: input.typeId != null ? input.typeId : undefined, + trackId: input.trackId != null ? input.trackId : undefined, + timelineTemplateId: input.timelineTemplateId != null ? input.timelineTemplateId : undefined, + legacy: input.legacy != null ? input.legacy : undefined, + billing: input.billing != null ? input.billing : undefined, + description: input.description != null ? sanitize(input.description, input.descriptionFormat ?? challenge.descriptionFormat) : undefined, + privateDescription: input.privateDescription != null ? sanitize(input.privateDescription, input.descriptionFormat ?? challenge.descriptionFormat) : undefined, + descriptionFormat: input.descriptionFormat != null ? input.descriptionFormat : undefined, + task: input.task != null ? input.task : undefined, + winners: input.winnerUpdate != null ? input.winnerUpdate.winners : undefined, + discussions: input.discussionUpdate != null ? input.discussionUpdate.discussions : undefined, + metadata: input.metadataUpdate != null ? input.metadataUpdate.metadata : undefined, + phases: input.phaseUpdate != null ? input.phaseUpdate.phases : undefined, + events: input.eventUpdate != null ? input.eventUpdate.events : undefined, + terms: input.termUpdate != null ? input.termUpdate.terms : undefined, + prizeSets: input.prizeSetUpdate != null ? input.prizeSetUpdate.prizeSets.map((prizeSet) => { + return { + ...prizeSet, + prizes: (prizeSet.prizes ?? []).map((prize) => { + return { + ...prize, + value: prize.amountInCents! / 100, + }; + }), + }; + }) : undefined, + tags: input.tagUpdate != null ? input.tagUpdate.tags : undefined, + status: input.status != null ? input.status : undefined, + attachments: input.attachmentUpdate != null ? input.attachmentUpdate.attachments : undefined, + groups: input.groupUpdate != null ? input.groupUpdate.groups : undefined, + projectId: input.projectId != null ? input.projectId : undefined, + startDate: input.startDate != null ? input.startDate : undefined, + endDate: input.endDate != null ? input.endDate : undefined, + overview: input.overview != null ? { + totalPrizes: totalPrizesInCents / 100, + totalPrizesInCents, + } : undefined, + legacyId: legacyId != null ? legacyId : undefined, + }, + metadata + ); + } catch (err) { + await lockConsumeAmount(baValidation, true); + throw err; + } if ( input.phaseUpdate?.phases && @@ -354,14 +394,9 @@ class ChallengeDomain extends CoreOperations { const updatedBy = "tcwebservice"; // TODO: Extract from interceptors const id = scanCriteria[0].value; - const challenge: Challenge | undefined = - !_.isUndefined(input.legacy) || - !_.isUndefined(input.phaseToClose) || - (input.phases?.phases && input.phases.phases.length) - ? await this.lookup(DomainHelper.getLookupCriteria("id", id)) - : undefined; - const data: IUpdateDataFromACL = {}; + const challenge = await this.lookup(DomainHelper.getLookupCriteria("id", id)); + let raiseEvent = false; if (!_.isUndefined(input.status)) { @@ -422,6 +457,22 @@ class ChallengeDomain extends CoreOperations { data.updated = new Date(); data.updatedBy = updatedBy; + // prettier-ignore + const prevTotalPrizesInCents = this.calculateTotalPrizesInCents(challenge?.prizeSets ?? []); + // prettier-ignore + const totalPrizesInCents = _.isArray(data.prizeSets) ? this.calculateTotalPrizesInCents(data.prizeSets!) : prevTotalPrizesInCents; + + const baValidation: BAValidation = { + challengeId: challenge?.id, + billingAccountId: challenge?.billing?.billingAccountId, + markup: challenge?.billing?.markup, + status: input.status ?? challenge?.status, + prevStatus: challenge?.status, + totalPrizesInCents, + prevTotalPrizesInCents, + }; + + await lockConsumeAmount(baValidation); const dynamoUpdate = _.omit(data, [ "currentPhase", "currentPhaseNames", @@ -431,7 +482,12 @@ class ChallengeDomain extends CoreOperations { "submissionEndDate", ]); - await super.update(scanCriteria, dynamoUpdate); + try { + await super.update(scanCriteria, dynamoUpdate); + } catch (err) { + await lockConsumeAmount(baValidation, true); + throw err; + } if (input.phases?.phases && input.phases.phases.length && this.shouldUseScheduler(challenge!)) { await ChallengeScheduler.schedule(id, input.phases.phases); @@ -464,6 +520,32 @@ class ChallengeDomain extends CoreOperations { } } + public async delete(lookupCriteria: LookupCriteria): Promise { + const challenge = await this.lookup(lookupCriteria); + + // prettier-ignore + const prevTotalPrizesInCents = this.calculateTotalPrizesInCents(challenge?.prizeSets ?? []); + + const baValidation: BAValidation = { + challengeId: challenge?.id, + billingAccountId: challenge?.billing?.billingAccountId, + markup: challenge?.billing?.markup, + status: ChallengeStatuses.Deleted, + prevStatus: challenge?.status, + prevTotalPrizesInCents, + totalPrizesInCents: prevTotalPrizesInCents, + }; + + await lockConsumeAmount(baValidation); + + try { + return super.delete(lookupCriteria); + } catch (err) { + await lockConsumeAmount(baValidation, true); + throw err; + } + } + public async getPhaseFacts(phaseFactRequest: PhaseFactRequest): Promise { // Just a pass through to the legacy domain - this is fine for now, but ideally these should be handled in the "future" review-api return legacyChallengeDomain.getPhaseFacts(phaseFactRequest);