@@ -2408,6 +2408,168 @@ function validateTask(currentUser, challenge, data, challengeResources) {
24082408 }
24092409}
24102410
2411+ /**
2412+ * Normalizes optional text values used by launch validation.
2413+ *
2414+ * @param {unknown } value Raw upstream value.
2415+ * @returns {string|undefined } Trimmed string or `undefined` when empty.
2416+ */
2417+ function normalizeOptionalString ( value ) {
2418+ if ( _ . isNil ( value ) ) {
2419+ return undefined ;
2420+ }
2421+
2422+ const normalizedValue = _ . toString ( value ) . trim ( ) ;
2423+
2424+ return normalizedValue || undefined ;
2425+ }
2426+
2427+ /**
2428+ * Normalizes optional numeric values used by launch validation.
2429+ *
2430+ * @param {unknown } value Raw upstream value.
2431+ * @returns {number|undefined } Parsed number or `undefined` when invalid.
2432+ */
2433+ function normalizeOptionalNumber ( value ) {
2434+ if ( _ . isNil ( value ) || value === "" ) {
2435+ return undefined ;
2436+ }
2437+
2438+ const normalizedValue = _ . toNumber ( value ) ;
2439+
2440+ return Number . isFinite ( normalizedValue ) ? normalizedValue : undefined ;
2441+ }
2442+
2443+ /**
2444+ * Resolves billing-account activity from either a boolean field or textual
2445+ * status returned by upstream services.
2446+ *
2447+ * @param {object|undefined|null } billingAccount Billing-account metadata.
2448+ * @returns {boolean|undefined } Billing-account activity flag when available.
2449+ */
2450+ function resolveBillingAccountActive ( billingAccount ) {
2451+ if ( _ . isBoolean ( _ . get ( billingAccount , "active" ) ) ) {
2452+ return billingAccount . active ;
2453+ }
2454+
2455+ const normalizedStatus = normalizeOptionalString ( _ . get ( billingAccount , "status" ) ) ;
2456+
2457+ if ( ! normalizedStatus ) {
2458+ return undefined ;
2459+ }
2460+
2461+ if ( normalizedStatus . toUpperCase ( ) === "ACTIVE" ) {
2462+ return true ;
2463+ }
2464+
2465+ if ( normalizedStatus . toUpperCase ( ) === "INACTIVE" ) {
2466+ return false ;
2467+ }
2468+
2469+ return undefined ;
2470+ }
2471+
2472+ /**
2473+ * Determines whether a billing account should be treated as expired.
2474+ *
2475+ * @param {boolean|undefined } active Billing-account activity flag.
2476+ * @param {string|undefined } endDate Billing-account end date.
2477+ * @returns {boolean } `true` when the billing account has expired.
2478+ */
2479+ function isBillingAccountExpired ( active , endDate ) {
2480+ if ( active === false ) {
2481+ return true ;
2482+ }
2483+
2484+ if ( ! endDate ) {
2485+ return false ;
2486+ }
2487+
2488+ const endDateTimestamp = Date . parse ( endDate ) ;
2489+
2490+ if ( Number . isNaN ( endDateTimestamp ) ) {
2491+ return false ;
2492+ }
2493+
2494+ return Date . now ( ) >= endDateTimestamp ;
2495+ }
2496+
2497+ /**
2498+ * Validates the project billing account before activating a challenge.
2499+ *
2500+ * @param {object } params Validation inputs.
2501+ * @param {boolean|undefined|null } params.active Billing-account activity returned by Projects API.
2502+ * @param {string|number|null|undefined } params.billingAccountId Project billing-account identifier.
2503+ * @param {object } params.challenge Existing challenge model.
2504+ * @param {string|undefined|null } params.endDate Billing-account end date returned by Projects API.
2505+ * @returns {Promise<void> } Resolves when the billing account can be used for launch.
2506+ * @throws {errors.BadRequestError } When the billing account is missing, inactive, expired, or depleted.
2507+ */
2508+ async function validateChallengeActivationBillingAccount ( {
2509+ active,
2510+ billingAccountId,
2511+ challenge,
2512+ endDate,
2513+ } ) {
2514+ if ( ! challengeHelper . isProjectIdRequired ( challenge . timelineTemplateId ) ) {
2515+ return ;
2516+ }
2517+
2518+ const normalizedBillingAccountId = normalizeOptionalString ( billingAccountId ) ;
2519+
2520+ if ( ! normalizedBillingAccountId ) {
2521+ throw new errors . BadRequestError (
2522+ "Cannot activate challenge because the project has no billing account."
2523+ ) ;
2524+ }
2525+
2526+ const resolvedProjectActive = _ . isBoolean ( active ) ? active : undefined ;
2527+ const resolvedProjectEndDate = normalizeOptionalString ( endDate ) ;
2528+
2529+ if ( resolvedProjectActive === false ) {
2530+ throw new errors . BadRequestError (
2531+ "Cannot activate challenge because the project billing account is inactive."
2532+ ) ;
2533+ }
2534+
2535+ if ( isBillingAccountExpired ( resolvedProjectActive , resolvedProjectEndDate ) ) {
2536+ throw new errors . BadRequestError (
2537+ "Cannot activate challenge because the project billing account is expired."
2538+ ) ;
2539+ }
2540+
2541+ const billingAccountDetails = await projectHelper . getBillingAccountDetails ( normalizedBillingAccountId ) ;
2542+ const resolvedActive = resolveBillingAccountActive ( billingAccountDetails ) ;
2543+ const resolvedEndDate =
2544+ normalizeOptionalString ( _ . get ( billingAccountDetails , "endDate" ) ) ;
2545+
2546+ if ( ! billingAccountDetails ) {
2547+ throw new errors . BadRequestError (
2548+ "Cannot activate challenge because the project billing account could not be found."
2549+ ) ;
2550+ }
2551+
2552+ if ( resolvedActive === false ) {
2553+ throw new errors . BadRequestError (
2554+ "Cannot activate challenge because the project billing account is inactive."
2555+ ) ;
2556+ }
2557+
2558+ if ( isBillingAccountExpired ( resolvedActive , resolvedEndDate ) ) {
2559+ throw new errors . BadRequestError (
2560+ "Cannot activate challenge because the project billing account is expired."
2561+ ) ;
2562+ }
2563+
2564+ const remainingBudget = normalizeOptionalNumber ( billingAccountDetails . totalBudgetRemaining ) ;
2565+
2566+ if ( ! _ . isNil ( remainingBudget ) && remainingBudget <= 0 ) {
2567+ throw new errors . BadRequestError (
2568+ "Cannot activate challenge because the project billing account has insufficient remaining funds."
2569+ ) ;
2570+ }
2571+ }
2572+
24112573function prepareTaskCompletionData ( challenge , challengeResources , data ) {
24122574 const isTask = helper . getTaskInfo ( challenge ) . isTask || _ . get ( challenge , "legacy.pureV5Task" ) ;
24132575 const isCompleteTask =
@@ -2500,14 +2662,19 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
25002662
25012663 // No conversion needed - values are already in dollars in the database
25022664
2503- let projectId , billingAccountId , markup ;
2665+ let projectId , billingAccountId , markup , billingAccountActive , billingAccountEndDate ;
25042666 if ( challengeHelper . isProjectIdRequired ( challenge . timelineTemplateId ) ) {
25052667 projectId = _ . get ( challenge , "projectId" ) ;
25062668
25072669 logger . debug (
25082670 `updateChallenge(${ challengeId } ): requesting billing information for project ${ projectId } ` ,
25092671 ) ;
2510- ( { billingAccountId, markup } = await projectHelper . getProjectBillingInformation ( projectId ) ) ;
2672+ ( {
2673+ billingAccountId,
2674+ markup,
2675+ active : billingAccountActive ,
2676+ endDate : billingAccountEndDate ,
2677+ } = await projectHelper . getProjectBillingInformation ( projectId ) ) ;
25112678 logger . debug (
25122679 `updateChallenge(${ challengeId } ): billing lookup complete (hasAccount=${
25132680 billingAccountId != null
@@ -2675,16 +2842,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
26752842 let isChallengeBeingCancelled = false ;
26762843 if ( data . status ) {
26772844 if ( data . status === ChallengeStatusEnum . ACTIVE ) {
2678- // if activating a challenge, the challenge must have a billing account id
2679- if (
2680- ( ! billingAccountId || billingAccountId === null ) &&
2681- challenge . status === ChallengeStatusEnum . DRAFT &&
2682- challengeHelper . isProjectIdRequired ( challenge . timelineTemplateId )
2683- ) {
2684- throw new errors . BadRequestError (
2685- "Cannot Activate this project, it has no active billing account." ,
2686- ) ;
2687- }
2845+ await validateChallengeActivationBillingAccount ( {
2846+ active : billingAccountActive ,
2847+ billingAccountId,
2848+ challenge,
2849+ endDate : billingAccountEndDate ,
2850+ } ) ;
26882851 }
26892852
26902853 if (
@@ -4425,6 +4588,9 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) {
44254588}
44264589
44274590module . exports = {
4591+ __testables : {
4592+ validateChallengeActivationBillingAccount,
4593+ } ,
44284594 searchChallenges,
44294595 createChallenge,
44304596 getChallenge,
0 commit comments