Skip to content

Commit b8190e6

Browse files
authored
Merge pull request #86 from topcoder-platform/PM-4686
PM-4686: block challenge activation for invalid billing accounts
2 parents dbde820 + 8df2511 commit b8190e6

File tree

5 files changed

+594
-23
lines changed

5 files changed

+594
-23
lines changed

config/default.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ module.exports = {
5858
CUSTOMER_PAYMENTS_URL:
5959
process.env.CUSTOMER_PAYMENTS_URL || "https://api.topcoder-dev.com/v5/customer-payments",
6060
FINANCE_API_URL: process.env.FINANCE_API_URL || "http://localhost:8080",
61+
BILLING_ACCOUNTS_API_URL:
62+
process.env.BILLING_ACCOUNTS_API_URL || "http://localhost:4000/v6/billing-accounts",
6163
CHALLENGE_MIGRATION_APP_URL:
6264
process.env.CHALLENGE_MIGRATION_APP_URL || "https://api.topcoder.com/v5/challenge-migration",
6365
// copilot resource role ids allowed to upload attachment

src/common/project-helper.js

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,64 @@ function normalizeBillingMarkup(rawMarkup) {
3333
return markup > 1 ? markup / 100 : markup;
3434
}
3535

36+
/**
37+
* Normalizes optional billing-account string values returned by upstream APIs.
38+
*
39+
* @param {unknown} rawValue String-like value from Projects API or Billing Accounts API.
40+
* @returns {string|null} Trimmed string or `null` when the value is empty.
41+
*/
42+
function normalizeOptionalString(rawValue) {
43+
if (_.isNil(rawValue)) {
44+
return null;
45+
}
46+
47+
const normalizedValue = _.toString(rawValue).trim();
48+
49+
return normalizedValue || null;
50+
}
51+
52+
/**
53+
* Normalizes optional billing-account boolean values returned by upstream APIs.
54+
*
55+
* @param {unknown} rawValue Boolean-like value from Projects API or Billing Accounts API.
56+
* @returns {boolean|null} Parsed boolean or `null` when the value cannot be resolved.
57+
*/
58+
function normalizeOptionalBoolean(rawValue) {
59+
if (_.isBoolean(rawValue)) {
60+
return rawValue;
61+
}
62+
63+
if (_.isString(rawValue)) {
64+
const normalizedValue = rawValue.trim().toLowerCase();
65+
66+
if (normalizedValue === "true") {
67+
return true;
68+
}
69+
70+
if (normalizedValue === "false") {
71+
return false;
72+
}
73+
}
74+
75+
return null;
76+
}
77+
78+
/**
79+
* Normalizes optional billing-account numeric values returned by upstream APIs.
80+
*
81+
* @param {unknown} rawValue Number-like value from Billing Accounts API.
82+
* @returns {number|null} Parsed finite number or `null` when the value is empty.
83+
*/
84+
function normalizeOptionalNumber(rawValue) {
85+
if (_.isNil(rawValue) || rawValue === "") {
86+
return null;
87+
}
88+
89+
const normalizedValue = _.toNumber(rawValue);
90+
91+
return Number.isFinite(normalizedValue) ? normalizedValue : null;
92+
}
93+
3694
class ProjectHelper {
3795
/**
3896
* Get Project Details.
@@ -89,10 +147,13 @@ class ProjectHelper {
89147
}
90148

91149
/**
92-
* This functions gets the default billing account for a given project id
150+
* Gets the default billing-account metadata for a project.
93151
*
94-
* @param {Number} projectId The id of the project for which to get the default terms of use
95-
* @returns {Promise<Number>} The billing account ID
152+
* Returns the linked billing-account id and markup for challenge persistence,
153+
* along with activity/expiry metadata used to validate challenge launches.
154+
*
155+
* @param {Number} projectId Project identifier whose default billing account should be fetched.
156+
* @returns {Promise<object>} Normalized billing-account fields resolved from Projects API.
96157
*/
97158
async getProjectBillingInformation(projectId) {
98159
const token = await m2mHelper.getM2MToken();
@@ -105,10 +166,14 @@ class ProjectHelper {
105166
logger.debug(
106167
`projectHelper.getProjectBillingInformation: response status ${res.status} for project ${projectId}`
107168
);
169+
const active = normalizeOptionalBoolean(_.get(res, "data.active", null));
170+
const endDate = normalizeOptionalString(_.get(res, "data.endDate", null));
108171

109172
return {
110173
billingAccountId: _.get(res, "data.tcBillingAccountId", null),
111174
markup: normalizeBillingMarkup(_.get(res, "data.markup", null)),
175+
...(active !== null ? { active } : {}),
176+
...(endDate ? { endDate } : {}),
112177
};
113178
} catch (err) {
114179
const responseCode = _.get(err, "response.status");
@@ -128,6 +193,61 @@ class ProjectHelper {
128193
}
129194
}
130195
}
196+
197+
/**
198+
* Gets detailed billing-account metadata needed for launch validation.
199+
*
200+
* The Billing Accounts API is the source of truth for lifecycle status and
201+
* remaining budget. Challenge launch validation uses this method to block
202+
* launches for inactive, expired, or depleted billing accounts.
203+
*
204+
* @param {string|number} billingAccountId Billing-account identifier to fetch.
205+
* @returns {Promise<object|null>} Normalized billing-account details, or `null` when not found.
206+
*/
207+
async getBillingAccountDetails(billingAccountId) {
208+
const normalizedBillingAccountId = normalizeOptionalString(billingAccountId);
209+
210+
if (!normalizedBillingAccountId) {
211+
return null;
212+
}
213+
214+
const token = await m2mHelper.getM2MToken();
215+
const url = `${config.BILLING_ACCOUNTS_API_URL}/${encodeURIComponent(normalizedBillingAccountId)}`;
216+
logger.debug(`projectHelper.getBillingAccountDetails: GET ${url}`);
217+
218+
try {
219+
const res = await axios.get(url, {
220+
headers: { Authorization: `Bearer ${token}` },
221+
});
222+
logger.debug(
223+
`projectHelper.getBillingAccountDetails: response status ${res.status} for billingAccountId ${normalizedBillingAccountId}`
224+
);
225+
226+
return {
227+
active: normalizeOptionalBoolean(_.get(res, "data.active", null)),
228+
billingAccountId:
229+
normalizeOptionalString(_.get(res, "data.id", null)) || normalizedBillingAccountId,
230+
endDate: normalizeOptionalString(_.get(res, "data.endDate", null)),
231+
status: normalizeOptionalString(_.get(res, "data.status", null)),
232+
totalBudgetRemaining: normalizeOptionalNumber(
233+
_.get(res, "data.totalBudgetRemaining", null)
234+
),
235+
};
236+
} catch (err) {
237+
const responseCode = _.get(err, "response.status");
238+
239+
if (responseCode === HttpStatus.NOT_FOUND) {
240+
return null;
241+
}
242+
243+
logger.debug(
244+
`projectHelper.getBillingAccountDetails: error for billingAccountId ${normalizedBillingAccountId} - status ${
245+
responseCode || "n/a"
246+
}: ${err.message}`
247+
);
248+
throw err;
249+
}
250+
}
131251
}
132252

133253
module.exports = new ProjectHelper();

src/services/ChallengeService.js

Lines changed: 178 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
24112573
function 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

44274590
module.exports = {
4591+
__testables: {
4592+
validateChallengeActivationBillingAccount,
4593+
},
44284594
searchChallenges,
44294595
createChallenge,
44304596
getChallenge,

0 commit comments

Comments
 (0)