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
2 changes: 2 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ module.exports = {
CUSTOMER_PAYMENTS_URL:
process.env.CUSTOMER_PAYMENTS_URL || "https://api.topcoder-dev.com/v5/customer-payments",
FINANCE_API_URL: process.env.FINANCE_API_URL || "http://localhost:8080",
BILLING_ACCOUNTS_API_URL:
process.env.BILLING_ACCOUNTS_API_URL || "http://localhost:4000/v6/billing-accounts",
CHALLENGE_MIGRATION_APP_URL:
process.env.CHALLENGE_MIGRATION_APP_URL || "https://api.topcoder.com/v5/challenge-migration",
// copilot resource role ids allowed to upload attachment
Expand Down
126 changes: 123 additions & 3 deletions src/common/project-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,64 @@ function normalizeBillingMarkup(rawMarkup) {
return markup > 1 ? markup / 100 : markup;
}

/**
* Normalizes optional billing-account string values returned by upstream APIs.
*
* @param {unknown} rawValue String-like value from Projects API or Billing Accounts API.
* @returns {string|null} Trimmed string or `null` when the value is empty.
*/
function normalizeOptionalString(rawValue) {
if (_.isNil(rawValue)) {
return null;
}

const normalizedValue = _.toString(rawValue).trim();

return normalizedValue || null;
}

/**
* Normalizes optional billing-account boolean values returned by upstream APIs.
*
* @param {unknown} rawValue Boolean-like value from Projects API or Billing Accounts API.
* @returns {boolean|null} Parsed boolean or `null` when the value cannot be resolved.
*/
function normalizeOptionalBoolean(rawValue) {
if (_.isBoolean(rawValue)) {
return rawValue;
}

if (_.isString(rawValue)) {
const normalizedValue = rawValue.trim().toLowerCase();

if (normalizedValue === "true") {
return true;
}

if (normalizedValue === "false") {
return false;
}
}

return null;
}

/**
* Normalizes optional billing-account numeric values returned by upstream APIs.
*
* @param {unknown} rawValue Number-like value from Billing Accounts API.
* @returns {number|null} Parsed finite number or `null` when the value is empty.
*/
function normalizeOptionalNumber(rawValue) {
if (_.isNil(rawValue) || rawValue === "") {
return null;
}

const normalizedValue = _.toNumber(rawValue);

return Number.isFinite(normalizedValue) ? normalizedValue : null;
}

class ProjectHelper {
/**
* Get Project Details.
Expand Down Expand Up @@ -89,10 +147,13 @@ class ProjectHelper {
}

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

return {
billingAccountId: _.get(res, "data.tcBillingAccountId", null),
markup: normalizeBillingMarkup(_.get(res, "data.markup", null)),
...(active !== null ? { active } : {}),
...(endDate ? { endDate } : {}),
};
} catch (err) {
const responseCode = _.get(err, "response.status");
Expand All @@ -128,6 +193,61 @@ class ProjectHelper {
}
}
}

/**
* Gets detailed billing-account metadata needed for launch validation.
*
* The Billing Accounts API is the source of truth for lifecycle status and
* remaining budget. Challenge launch validation uses this method to block
* launches for inactive, expired, or depleted billing accounts.
*
* @param {string|number} billingAccountId Billing-account identifier to fetch.
* @returns {Promise<object|null>} Normalized billing-account details, or `null` when not found.
*/
async getBillingAccountDetails(billingAccountId) {
const normalizedBillingAccountId = normalizeOptionalString(billingAccountId);

if (!normalizedBillingAccountId) {
return null;
}

const token = await m2mHelper.getM2MToken();
const url = `${config.BILLING_ACCOUNTS_API_URL}/${encodeURIComponent(normalizedBillingAccountId)}`;
logger.debug(`projectHelper.getBillingAccountDetails: GET ${url}`);

try {
const res = await axios.get(url, {
headers: { Authorization: `Bearer ${token}` },
});
logger.debug(
`projectHelper.getBillingAccountDetails: response status ${res.status} for billingAccountId ${normalizedBillingAccountId}`
);

return {
active: normalizeOptionalBoolean(_.get(res, "data.active", null)),
billingAccountId:
normalizeOptionalString(_.get(res, "data.id", null)) || normalizedBillingAccountId,
endDate: normalizeOptionalString(_.get(res, "data.endDate", null)),
status: normalizeOptionalString(_.get(res, "data.status", null)),
totalBudgetRemaining: normalizeOptionalNumber(
_.get(res, "data.totalBudgetRemaining", null)
),
};
} catch (err) {
const responseCode = _.get(err, "response.status");

if (responseCode === HttpStatus.NOT_FOUND) {
return null;
}

logger.debug(
`projectHelper.getBillingAccountDetails: error for billingAccountId ${normalizedBillingAccountId} - status ${
responseCode || "n/a"
}: ${err.message}`
);
throw err;
}
}
}

module.exports = new ProjectHelper();
190 changes: 178 additions & 12 deletions src/services/ChallengeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2408,6 +2408,168 @@ function validateTask(currentUser, challenge, data, challengeResources) {
}
}

/**
* Normalizes optional text values used by launch validation.
*
* @param {unknown} value Raw upstream value.
* @returns {string|undefined} Trimmed string or `undefined` when empty.
*/
function normalizeOptionalString(value) {
if (_.isNil(value)) {
return undefined;
}

const normalizedValue = _.toString(value).trim();

return normalizedValue || undefined;
}

/**
* Normalizes optional numeric values used by launch validation.
*
* @param {unknown} value Raw upstream value.
* @returns {number|undefined} Parsed number or `undefined` when invalid.
*/
function normalizeOptionalNumber(value) {
if (_.isNil(value) || value === "") {
return undefined;
}

const normalizedValue = _.toNumber(value);

return Number.isFinite(normalizedValue) ? normalizedValue : undefined;
}

/**
* Resolves billing-account activity from either a boolean field or textual
* status returned by upstream services.
*
* @param {object|undefined|null} billingAccount Billing-account metadata.
* @returns {boolean|undefined} Billing-account activity flag when available.
*/
function resolveBillingAccountActive(billingAccount) {
if (_.isBoolean(_.get(billingAccount, "active"))) {
return billingAccount.active;
}

const normalizedStatus = normalizeOptionalString(_.get(billingAccount, "status"));

if (!normalizedStatus) {
return undefined;
}

if (normalizedStatus.toUpperCase() === "ACTIVE") {
return true;
}

if (normalizedStatus.toUpperCase() === "INACTIVE") {
return false;
}

return undefined;
}

/**
* Determines whether a billing account should be treated as expired.
*
* @param {boolean|undefined} active Billing-account activity flag.
* @param {string|undefined} endDate Billing-account end date.
* @returns {boolean} `true` when the billing account has expired.
*/
function isBillingAccountExpired(active, endDate) {
if (active === false) {
return true;
}

if (!endDate) {
return false;
}

const endDateTimestamp = Date.parse(endDate);

if (Number.isNaN(endDateTimestamp)) {
return false;
}

return Date.now() >= endDateTimestamp;
}

/**
* Validates the project billing account before activating a challenge.
*
* @param {object} params Validation inputs.
* @param {boolean|undefined|null} params.active Billing-account activity returned by Projects API.
* @param {string|number|null|undefined} params.billingAccountId Project billing-account identifier.
* @param {object} params.challenge Existing challenge model.
* @param {string|undefined|null} params.endDate Billing-account end date returned by Projects API.
* @returns {Promise<void>} Resolves when the billing account can be used for launch.
* @throws {errors.BadRequestError} When the billing account is missing, inactive, expired, or depleted.
*/
async function validateChallengeActivationBillingAccount({
active,
billingAccountId,
challenge,
endDate,
}) {
if (!challengeHelper.isProjectIdRequired(challenge.timelineTemplateId)) {
return;
}

const normalizedBillingAccountId = normalizeOptionalString(billingAccountId);

if (!normalizedBillingAccountId) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project has no billing account."
);
}

const resolvedProjectActive = _.isBoolean(active) ? active : undefined;
const resolvedProjectEndDate = normalizeOptionalString(endDate);

if (resolvedProjectActive === false) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account is inactive."
);
}

if (isBillingAccountExpired(resolvedProjectActive, resolvedProjectEndDate)) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account is expired."
);
}

const billingAccountDetails = await projectHelper.getBillingAccountDetails(normalizedBillingAccountId);
const resolvedActive = resolveBillingAccountActive(billingAccountDetails);
const resolvedEndDate =
normalizeOptionalString(_.get(billingAccountDetails, "endDate"));

if (!billingAccountDetails) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account could not be found."
);
}

if (resolvedActive === false) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account is inactive."
);
}

if (isBillingAccountExpired(resolvedActive, resolvedEndDate)) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account is expired."
);
}

const remainingBudget = normalizeOptionalNumber(billingAccountDetails.totalBudgetRemaining);

if (!_.isNil(remainingBudget) && remainingBudget <= 0) {
throw new errors.BadRequestError(
"Cannot activate challenge because the project billing account has insufficient remaining funds."
);
}
}

function prepareTaskCompletionData(challenge, challengeResources, data) {
const isTask = helper.getTaskInfo(challenge).isTask || _.get(challenge, "legacy.pureV5Task");
const isCompleteTask =
Expand Down Expand Up @@ -2500,14 +2662,19 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {

// No conversion needed - values are already in dollars in the database

let projectId, billingAccountId, markup;
let projectId, billingAccountId, markup, billingAccountActive, billingAccountEndDate;
if (challengeHelper.isProjectIdRequired(challenge.timelineTemplateId)) {
projectId = _.get(challenge, "projectId");

logger.debug(
`updateChallenge(${challengeId}): requesting billing information for project ${projectId}`,
);
({ billingAccountId, markup } = await projectHelper.getProjectBillingInformation(projectId));
({
billingAccountId,
markup,
active: billingAccountActive,
endDate: billingAccountEndDate,
} = await projectHelper.getProjectBillingInformation(projectId));
logger.debug(
`updateChallenge(${challengeId}): billing lookup complete (hasAccount=${
billingAccountId != null
Expand Down Expand Up @@ -2675,16 +2842,12 @@ async function updateChallenge(currentUser, challengeId, data, options = {}) {
let isChallengeBeingCancelled = false;
if (data.status) {
if (data.status === ChallengeStatusEnum.ACTIVE) {
// if activating a challenge, the challenge must have a billing account id
if (
(!billingAccountId || billingAccountId === null) &&
challenge.status === ChallengeStatusEnum.DRAFT &&
challengeHelper.isProjectIdRequired(challenge.timelineTemplateId)
) {
throw new errors.BadRequestError(
"Cannot Activate this project, it has no active billing account.",
);
}
await validateChallengeActivationBillingAccount({
active: billingAccountActive,
billingAccountId,
challenge,
endDate: billingAccountEndDate,
});
}

if (
Expand Down Expand Up @@ -4425,6 +4588,9 @@ async function indexChallengeAndPostToKafka(updatedChallenge, track, type) {
}

module.exports = {
__testables: {
validateChallengeActivationBillingAccount,
},
searchChallenges,
createChallenge,
getChallenge,
Expand Down
Loading
Loading