diff --git a/config/default.js b/config/default.js index 687c96c..02388ba 100644 --- a/config/default.js +++ b/config/default.js @@ -81,9 +81,11 @@ module.exports = { // topgear billing accounts TOPGEAR_BILLING_ACCOUNTS_ID: process.env.TOPGEAR_BILLING_ACCOUNTS_ID ? process.env.TOPGEAR_BILLING_ACCOUNTS_ID.split(",") + .map((billingAccountId) => billingAccountId.trim()) + .filter(Boolean) : [], - // billing accounts that can bypass challenge activation expiry/funds validation + // billing accounts that can bypass challenge activation expiry/funds validation and budget locks IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS: process.env .IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS ? process.env.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS.split(",") diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 7dc668b..a94c97b 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -126,6 +126,157 @@ function normalizeApprovalStatus(value) { return normalized; } +/** + * Normalizes a configured billing-account list for membership checks. + * + * @param {Array|string|number|null|undefined} billingAccountIds Billing-account ids from config. + * @returns {Array} Trimmed billing-account ids, with empty values removed. + */ +function normalizeConfiguredBillingAccountIds(billingAccountIds) { + if (_.isNil(billingAccountIds)) { + return []; + } + + return _.flatMap([].concat(billingAccountIds), (billingAccountId) => + _.toString(billingAccountId).split(","), + ) + .map(normalizeOptionalString) + .filter(Boolean); +} + +/** + * Resolves the billing account that should drive challenge approval decisions. + * + * @param {Object|null|undefined} challenge Existing or incoming challenge payload. + * @param {Object|null|undefined} data Incoming update payload, when applicable. + * @param {string|number|null|undefined} projectBillingAccountId Billing account returned by the project. + * @returns {string|null} The first available billing-account id, or `null` when none is present. + */ +function getApprovalFlowBillingAccountId(challenge, data, projectBillingAccountId) { + return ( + normalizeOptionalString(projectBillingAccountId) || + normalizeOptionalString(_.get(data, "billing.billingAccountId")) || + normalizeOptionalString(_.get(challenge, "billing.billingAccountId")) || + normalizeOptionalString(_.get(challenge, "billingRecord.billingAccountId")) || + null + ); +} + +/** + * Determines whether the challenge approval flow should be bypassed. + * + * Challenges billed to configured Topgear billing accounts are auto-approved + * because they should not enter the manual budget approval flow. + * + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when challenge approval should be skipped. + */ +function shouldSkipChallengeApprovalFlow(billingAccountId) { + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); + + if (!normalizedBillingAccountId) { + return false; + } + + return _.includes( + normalizeConfiguredBillingAccountIds(config.TOPGEAR_BILLING_ACCOUNTS_ID), + normalizedBillingAccountId, + ); +} + +/** + * Applies the approval-flow bypass to a challenge payload. + * + * @param {Object} target Challenge create or update payload to mutate. + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when approval fields were forced to approved. + */ +function applyChallengeApprovalFlowBypass(target, billingAccountId) { + if (!shouldSkipChallengeApprovalFlow(billingAccountId)) { + return false; + } + + target.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + target.approvalRejectionReason = null; + target.approvalApprovedBy = null; + + return true; +} + +/** + * Applies the temporary create-time approval hotfix to a challenge payload. + * + * Challenges created in NEW or DRAFT status are forced to approved while the + * budget approval flow is being investigated. A missing create status is + * treated as NEW because createChallenge defaults it later in the workflow. + * + * @param {Object} challenge Challenge create payload to mutate. + * @returns {boolean} `true` when approval fields were forced to approved. + */ +function applyCreateChallengeApprovalStatusHotfix(challenge) { + const challengeStatus = normalizeStatusSortValue(challenge.status || ChallengeStatusEnum.NEW); + + if ( + challengeStatus !== ChallengeStatusEnum.NEW && + challengeStatus !== ChallengeStatusEnum.DRAFT + ) { + return false; + } + + challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = null; + + return true; +} + +/** + * Applies the temporary NEW-to-DRAFT approval preservation hotfix to an update payload. + * + * Challenges auto-approved during creation must keep that approved state when + * saved from NEW to DRAFT, even if the same PATCH includes prize data. + * + * @param {Object} challenge Existing challenge response payload. + * @param {Object} data Sanitized challenge update payload to mutate. + * @param {string|null|undefined} requestedApprovalStatus Valid approval status from the update payload. + * @returns {boolean} `true` when approval fields were forced to remain approved. + */ +function applyNewDraftApprovalStatusPreservationHotfix(challenge, data, requestedApprovalStatus) { + const currentStatus = normalizeStatusSortValue(challenge.status); + const targetStatus = normalizeStatusSortValue(data.status || challenge.status); + const currentApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); + + if ( + currentStatus !== ChallengeStatusEnum.NEW || + targetStatus !== ChallengeStatusEnum.DRAFT || + currentApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED || + (requestedApprovalStatus != null && + requestedApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED) + ) { + return false; + } + + data.approvalStatus = CHALLENGE_APPROVAL_STATUS.APPROVED; + data.approvalRejectionReason = null; + delete data.approvalApprovedBy; + + return true; +} + +/** + * Determines whether challenge activation must wait for budget approval. + * + * @param {string|null|undefined} approvalStatus Effective approval status. + * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @returns {boolean} `true` when launch should be blocked by approval state. + */ +function shouldBlockChallengeLaunchForApproval(approvalStatus, billingAccountId) { + return ( + !shouldSkipChallengeApprovalFlow(billingAccountId) && + normalizeApprovalStatus(approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED + ); +} + async function userCanApproveChallengeBudget(currentUser, challengeOrProjectId) { if (!currentUser) { return false; @@ -308,6 +459,9 @@ function getChallengeMemberPaymentAmount(challenge) { * finance later consumes the finalized payment amount after payment generation. * This keeps draft challenge rows visible as locked budget in billing-account * details until finance moves the row to consumed. + * Accounts configured to ignore challenge activation billing validation also + * skip this lock because the Billing Accounts API validates available funds + * when writing the lock. * * @param {object} challenge Challenge model or response object after persistence. * @returns {Promise} Resolves after the billing-account lock is written or skipped. @@ -323,6 +477,14 @@ async function syncChallengeBillingAccountLock(challenge) { const hasBillingAccountId = !_.isNil(billingAccountId) && _.toString(billingAccountId).trim(); const memberPaymentAmount = getChallengeMemberPaymentAmount(challenge); + if (shouldIgnoreChallengeActivationBillingValidation(billingAccountId)) { + logger.info("Skipping challenge billing lock sync for ignored billing account", { + billingAccountId, + challengeId: _.get(challenge, "id"), + }); + return; + } + if (!hasBillingAccountId || _.isNil(memberPaymentAmount)) { logger.warn("Skipping challenge billing lock sync due to missing billing context", { challengeId: _.get(challenge, "id"), @@ -2008,6 +2170,8 @@ searchChallenges.schema = { /** * Create challenge. + * Temporary hotfix: NEW and DRAFT challenge creations are auto-approved. + * Challenges billed to configured Topgear accounts skip manual budget approval and are auto-approved. * @param {Object} currentUser the user who perform operation * @param {Object} challenge the challenge to created * @param {String} userToken the user token @@ -2112,41 +2276,55 @@ async function createChallenge(currentUser, challenge, userToken) { _.set(challenge, "legacy.reviewType", _.toUpper(_.get(challenge, "legacy.reviewType"))); } - const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); - const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge); - - if (!requestedApprovalStatus) { - challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; - } else { - challenge.approvalStatus = requestedApprovalStatus; + if (!challenge.status) { + challenge.status = ChallengeStatusEnum.NEW; } - if ( - CHALLENGE_APPROVAL_ACTION_STATUSES.has(challenge.approvalStatus) && - !canApproveChallengeBudget - ) { - throw new errors.ForbiddenError( - "Only admins or project managers with full access can approve or reject challenge budgets.", + let skipsChallengeApprovalFlow = applyCreateChallengeApprovalStatusHotfix(challenge); + + if (!skipsChallengeApprovalFlow) { + const approvalBillingAccountId = getApprovalFlowBillingAccountId(challenge); + skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( + challenge, + approvalBillingAccountId, ); } - if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { - const rejectionReason = _.toString(challenge.approvalRejectionReason || "").trim(); - if (!rejectionReason) { - throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge."); + if (!skipsChallengeApprovalFlow) { + const requestedApprovalStatus = normalizeApprovalStatus(challenge.approvalStatus); + const canApproveChallengeBudget = await userCanApproveChallengeBudget(currentUser, challenge); + + if (!requestedApprovalStatus) { + challenge.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; + } else { + challenge.approvalStatus = requestedApprovalStatus; } - challenge.approvalRejectionReason = rejectionReason; - challenge.approvalApprovedBy = null; - } else if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { - challenge.approvalRejectionReason = null; - challenge.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; - } else { - challenge.approvalRejectionReason = null; - challenge.approvalApprovedBy = null; - } - if (!challenge.status) { - challenge.status = ChallengeStatusEnum.NEW; + if ( + CHALLENGE_APPROVAL_ACTION_STATUSES.has(challenge.approvalStatus) && + !canApproveChallengeBudget + ) { + throw new errors.ForbiddenError( + "Only admins or project managers with full access can approve or reject challenge budgets.", + ); + } + + if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { + const rejectionReason = _.toString(challenge.approvalRejectionReason || "").trim(); + if (!rejectionReason) { + throw new errors.BadRequestError( + "Rejection reason is required when rejecting a challenge.", + ); + } + challenge.approvalRejectionReason = rejectionReason; + challenge.approvalApprovedBy = null; + } else if (challenge.approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; + } else { + challenge.approvalRejectionReason = null; + challenge.approvalApprovedBy = null; + } } if (!challenge.startDate) { @@ -2706,7 +2884,9 @@ function isDifferentPrizeSets(prizeSets = [], otherPrizeSets = []) { const buildPrizeKeys = (sets) => _.sortBy( _.flatMap(sets || [], (prizeSet) => { - const normalizedType = _.toString(_.get(prizeSet, "type", "")).trim().toUpperCase(); + const normalizedType = _.toString(_.get(prizeSet, "type", "")) + .trim() + .toUpperCase(); const prizes = Array.isArray(prizeSet && prizeSet.prizes) ? prizeSet.prizes : []; return prizes.map((prize) => { @@ -2925,12 +3105,13 @@ function isBillingAccountExpired(active, endDate) { } /** - * Determines whether challenge activation should skip expiry/funds validation - * for a billing account. + * Determines whether challenge billing funds checks should be skipped for a + * billing account. * * Missing, inactive, and not-found checks still apply. The bypass is intended * for specific accounts that must remain launchable despite expired dates or - * depleted remaining budget. + * depleted remaining budget, and that should not call the budget-lock endpoint + * because it enforces the same remaining-funds constraint. * * @param {string|number|null|undefined} billingAccountId Billing-account identifier. * @returns {boolean} `true` when expiry/funds validation should be skipped. @@ -2943,7 +3124,7 @@ function shouldIgnoreChallengeActivationBillingValidation(billingAccountId) { } return _.includes( - _.map(config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS, normalizeOptionalString), + normalizeConfiguredBillingAccountIds(config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS), normalizedBillingAccountId, ); } @@ -3097,6 +3278,7 @@ function prepareTaskCompletionData(challenge, challengeResources, data) { * Update challenge. * When a challenge transitions to completed task status or a cancelled status, * payment generation is requested after the database update commits. + * Challenges billed to configured Topgear accounts skip manual budget approval and remain approved. * @param {Object} currentUser the user who perform operation * @param {String} challengeId the challenge id * @param {Object} data the challenge data to be updated @@ -3192,30 +3374,60 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { const requestedApprovalStatus = normalizeApprovalStatus(data.approvalStatus); const prizeSetsUpdated = Array.isArray(data.prizeSets) && isDifferentPrizeSets(data.prizeSets, challenge.prizeSets); + const approvalBillingAccountId = getApprovalFlowBillingAccountId( + challenge, + data, + billingAccountId, + ); + const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( + data, + approvalBillingAccountId, + ); - if ( - requestedApprovalStatus != null && - requestedApprovalStatus !== challenge.approvalStatus && - !canApproveChallengeBudget - ) { - throw new errors.ForbiddenError( - "Only admins or project managers with full access can change the challenge budget approval status.", + if (!skipsChallengeApprovalFlow) { + if ( + requestedApprovalStatus != null && + requestedApprovalStatus !== challenge.approvalStatus && + !canApproveChallengeBudget + ) { + throw new errors.ForbiddenError( + "Only admins or project managers with full access can change the challenge budget approval status.", + ); + } + + if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { + const rejectionReason = rawApprovalRejectionReason.trim(); + if (!rejectionReason) { + throw new errors.BadRequestError( + "Rejection reason is required when rejecting a challenge.", + ); + } + data.approvalRejectionReason = rejectionReason; + data.approvalApprovedBy = null; + } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { + data.approvalRejectionReason = null; + data.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; + } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL) { + data.approvalRejectionReason = null; + data.approvalApprovedBy = null; + } + + const preservesNewDraftApprovalStatus = applyNewDraftApprovalStatusPreservationHotfix( + challenge, + data, + requestedApprovalStatus, ); - } - if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) { - const rejectionReason = rawApprovalRejectionReason.trim(); - if (!rejectionReason) { - throw new errors.BadRequestError("Rejection reason is required when rejecting a challenge."); + if ( + prizeSetsUpdated && + challenge.status !== ChallengeStatusEnum.ACTIVE && + !preservesNewDraftApprovalStatus && + (requestedApprovalStatus == null || !canApproveChallengeBudget) + ) { + data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; + data.approvalRejectionReason = null; + data.approvalApprovedBy = null; } - data.approvalRejectionReason = rejectionReason; - data.approvalApprovedBy = null; - } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { - data.approvalRejectionReason = null; - data.approvalApprovedBy = _.toString(currentUser.handle || "").trim() || null; - } else if (requestedApprovalStatus === CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL) { - data.approvalRejectionReason = null; - data.approvalApprovedBy = null; } if ( @@ -3228,16 +3440,6 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { ); } - if ( - prizeSetsUpdated && - challenge.status !== ChallengeStatusEnum.ACTIVE && - (requestedApprovalStatus == null || !canApproveChallengeBudget) - ) { - data.approvalStatus = CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL; - data.approvalRejectionReason = null; - data.approvalApprovedBy = null; - } - const resolvedApprovalStatus = normalizeApprovalStatus(data.approvalStatus) || normalizeApprovalStatus(challenge.approvalStatus) || @@ -3267,8 +3469,13 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) { data.status === ChallengeStatusEnum.COMPLETED && challenge.status !== ChallengeStatusEnum.COMPLETED; - if (isStatusChangingToActive && resolvedApprovalStatus !== CHALLENGE_APPROVAL_STATUS.APPROVED) { - throw new errors.BadRequestError("Challenge launch is blocked until budget approval is Approved."); + if ( + isStatusChangingToActive && + shouldBlockChallengeLaunchForApproval(resolvedApprovalStatus, approvalBillingAccountId) + ) { + throw new errors.BadRequestError( + "Challenge launch is blocked until budget approval is Approved.", + ); } let sendActivationEmail = false; @@ -5140,6 +5347,11 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) { module.exports = { __testables: { + applyCreateChallengeApprovalStatusHotfix, + applyNewDraftApprovalStatusPreservationHotfix, + shouldBlockChallengeLaunchForApproval, + shouldSkipChallengeApprovalFlow, + syncChallengeBillingAccountLock, validateChallengeActivationBillingAccount, }, searchChallenges, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 87a2a89..f7002cf 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -1759,23 +1759,30 @@ describe("challenge service unit tests", () => { config.M2M_FULL_ACCESS_TOKEN, ); createdChallengeId = created.id; + should.equal(created.approvalStatus, "APPROVED"); should.equal(billingLockRequests.length, 0); + const draftPrizeSets = _.cloneDeep(challengeData.prizeSets); + draftPrizeSets[0].prizes[0].value = 1005; + const draft = await service.updateChallenge( { isMachine: true, sub: "sub-billing-lock-update", userId: 22838965 }, created.id, { + approvalStatus: "APPROVED", + prizeSets: draftPrizeSets, status: ChallengeStatusEnum.DRAFT, }, ); + should.equal(draft.approvalStatus, "APPROVED"); should.equal(draft.billing.billingAccountId, "80001012"); should.equal(billingLockRequests.length, 1); billingLockRequests[0].should.deep.equal({ billingAccountId: "80001012", challengeId: created.id, markup: 0.1, - memberPaymentAmount: 1150, + memberPaymentAmount: 1155, }); const updatedPrizeSets = _.cloneDeep(draft.prizeSets); @@ -1800,7 +1807,7 @@ describe("challenge service unit tests", () => { billingAccountId: "80001012", challengeId: created.id, markup: 0.1, - memberPaymentAmount: 1225, + memberPaymentAmount: 1230, }); } finally { projectHelper.getProject = originalGetProject; diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index eadcc58..4e9c25e 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -4,6 +4,7 @@ if (!process.env.REVIEW_DB_URL && process.env.DATABASE_URL) { require("../../app-bootstrap"); const chai = require("chai"); +const config = require("config"); const service = require("../../src/services/ChallengeService"); const projectHelper = require("../../src/common/project-helper"); const { ChallengeStatusEnum } = require("../../src/common/prisma"); @@ -11,16 +12,30 @@ const { ChallengeStatusEnum } = require("../../src/common/prisma"); const should = chai.should(); describe("challenge activation billing validation unit tests", () => { + const applyCreateChallengeApprovalStatusHotfix = + service.__testables.applyCreateChallengeApprovalStatusHotfix; + const applyNewDraftApprovalStatusPreservationHotfix = + service.__testables.applyNewDraftApprovalStatusPreservationHotfix; const validateChallengeActivationBillingAccount = service.__testables.validateChallengeActivationBillingAccount; + const shouldBlockChallengeLaunchForApproval = + service.__testables.shouldBlockChallengeLaunchForApproval; + const shouldSkipChallengeApprovalFlow = service.__testables.shouldSkipChallengeApprovalFlow; + const syncChallengeBillingAccountLock = service.__testables.syncChallengeBillingAccountLock; const projectChallenge = { status: ChallengeStatusEnum.DRAFT, timelineTemplateId: "project-required-template", }; const originalGetBillingAccountDetails = projectHelper.getBillingAccountDetails; + const originalLockChallengeBillingAccountAmount = projectHelper.lockChallengeBillingAccountAmount; + const originalIgnoredBillingAccounts = config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS; + const originalTopgearBillingAccounts = config.TOPGEAR_BILLING_ACCOUNTS_ID; afterEach(() => { projectHelper.getBillingAccountDetails = originalGetBillingAccountDetails; + projectHelper.lockChallengeBillingAccountAmount = originalLockChallengeBillingAccountAmount; + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = originalIgnoredBillingAccounts; + config.TOPGEAR_BILLING_ACCOUNTS_ID = originalTopgearBillingAccounts; }); it("prevents activation when the project has no billing account", async () => { @@ -137,4 +152,141 @@ describe("challenge activation billing validation unit tests", () => { endDate: "2000-01-01T00:00:00.000Z", }); }); + + it("skips approval flow for configured Topgear billing accounts", () => { + config.TOPGEAR_BILLING_ACCOUNTS_ID = [" 80000062 ", 80000063]; + + should.equal(shouldSkipChallengeApprovalFlow("80000062"), true); + should.equal(shouldSkipChallengeApprovalFlow(80000063), true); + should.equal(shouldSkipChallengeApprovalFlow("80001061"), false); + }); + + it("does not block launch approval for configured Topgear billing accounts", () => { + config.TOPGEAR_BILLING_ACCOUNTS_ID = ["80000062"]; + + should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80000062"), false); + should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061"), true); + should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false); + }); + + it("auto-approves NEW and DRAFT challenge creation payloads", () => { + const defaultNewChallenge = {}; + const draftChallenge = { + status: ChallengeStatusEnum.DRAFT, + approvalStatus: "REJECTED", + approvalRejectionReason: "too expensive", + approvalApprovedBy: "approver", + }; + const approvedChallenge = { + status: ChallengeStatusEnum.APPROVED, + }; + + should.equal(applyCreateChallengeApprovalStatusHotfix(defaultNewChallenge), true); + should.equal(defaultNewChallenge.approvalStatus, "APPROVED"); + should.equal(defaultNewChallenge.approvalRejectionReason, null); + should.equal(defaultNewChallenge.approvalApprovedBy, null); + + should.equal(applyCreateChallengeApprovalStatusHotfix(draftChallenge), true); + should.equal(draftChallenge.approvalStatus, "APPROVED"); + should.equal(draftChallenge.approvalRejectionReason, null); + should.equal(draftChallenge.approvalApprovedBy, null); + + should.equal(applyCreateChallengeApprovalStatusHotfix(approvedChallenge), false); + should.equal(approvedChallenge.approvalStatus, undefined); + }); + + it("keeps approved status when an approved NEW challenge is saved as DRAFT", () => { + const existingChallenge = { + status: ChallengeStatusEnum.NEW, + approvalStatus: "APPROVED", + approvalApprovedBy: "existing-approver", + }; + const updatePayload = { + status: ChallengeStatusEnum.DRAFT, + approvalApprovedBy: "incoming-approver", + }; + const pendingChallenge = { + status: ChallengeStatusEnum.NEW, + approvalStatus: "PENDING_APPROVAL", + }; + const pendingUpdatePayload = { + status: ChallengeStatusEnum.DRAFT, + }; + const rejectedUpdatePayload = { + status: ChallengeStatusEnum.DRAFT, + }; + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix(existingChallenge, updatePayload), + true, + ); + should.equal(updatePayload.approvalStatus, "APPROVED"); + should.equal(updatePayload.approvalRejectionReason, null); + should.equal(updatePayload.approvalApprovedBy, undefined); + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix(pendingChallenge, pendingUpdatePayload), + false, + ); + should.equal(pendingUpdatePayload.approvalStatus, undefined); + + should.equal( + applyNewDraftApprovalStatusPreservationHotfix( + existingChallenge, + rejectedUpdatePayload, + "REJECTED", + ), + false, + ); + should.equal(rejectedUpdatePayload.approvalStatus, undefined); + }); + + it("skips budget lock funds validation for ignored billing accounts", async () => { + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; + let lockCalled = false; + projectHelper.lockChallengeBillingAccountAmount = async () => { + lockCalled = true; + }; + + await syncChallengeBillingAccountLock({ + id: "challenge-id", + status: ChallengeStatusEnum.DRAFT, + billing: { + billingAccountId: "80000062", + markup: 0, + }, + overview: { + totalPrizes: 100, + }, + }); + + should.equal(lockCalled, false); + }); + + it("continues budget lock sync for non-ignored billing accounts", async () => { + config.IGNORED_CHALLENGE_ACTIVATION_BILLING_ACCOUNT_IDS = ["80000062"]; + let lockRequest; + projectHelper.lockChallengeBillingAccountAmount = async (request) => { + lockRequest = request; + }; + + await syncChallengeBillingAccountLock({ + id: "challenge-id", + status: ChallengeStatusEnum.DRAFT, + billing: { + billingAccountId: "80001061", + markup: 0.1, + }, + overview: { + totalPrizes: 100, + }, + }); + + lockRequest.should.deep.equal({ + billingAccountId: "80001061", + challengeId: "challenge-id", + markup: 0.1, + memberPaymentAmount: 100, + }); + }); });