diff --git a/migrations/20260615140000-add-promo-code-indexes.js b/migrations/20260615140000-add-promo-code-indexes.js new file mode 100644 index 000000000..c9997b36a --- /dev/null +++ b/migrations/20260615140000-add-promo-code-indexes.js @@ -0,0 +1,29 @@ +/** + * @file Migration to add indexes for promoCodes and promoCodeUsages collections + */ +module.exports = { + async up(db) { + const promoCodes = db.collection('promoCodes'); + const promoCodeUsages = db.collection('promoCodeUsages'); + + await promoCodes.createIndex({ value: 1 }, { unique: true }); + + await promoCodeUsages.createIndex({ promoCodeId: 1 }); + await promoCodeUsages.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true }); + await promoCodeUsages.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true }); + await promoCodeUsages.createIndex({ workspaceId: 1 }); + await promoCodeUsages.createIndex({ userId: 1 }); + }, + + async down(db) { + const promoCodes = db.collection('promoCodes'); + const promoCodeUsages = db.collection('promoCodeUsages'); + + await promoCodes.dropIndex('value_1'); + await promoCodeUsages.dropIndex('promoCodeId_1'); + await promoCodeUsages.dropIndex('promoCodeId_1_userId_1'); + await promoCodeUsages.dropIndex('promoCodeId_1_workspaceId_1'); + await promoCodeUsages.dropIndex('workspaceId_1'); + await promoCodeUsages.dropIndex('userId_1'); + }, +}; diff --git a/package.json b/package.json index 2eb5eb563..7e68ab31d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.3", + "version": "1.5.4", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -42,7 +42,7 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.3.2", - "@hawk.so/types": "^0.5.9", + "@hawk.so/types": "^0.6.3", "@n1ru4l/json-patch-plus": "^0.2.0", "@node-saml/node-saml": "^5.0.1", "@octokit/oauth-methods": "^4.0.0", diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index bcee09e0b..ba9dd777d 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -42,6 +42,7 @@ import { PaymentData } from './types/paymentData'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import PlanModel from '../models/plan'; import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments'; +import PromoCodeService from '../services/promoCodeService'; const PENNY_MULTIPLIER = 100; @@ -101,9 +102,15 @@ export default class CloudPaymentsWebhooks { } /** - * Route to confirm the correctness of a user's payment + * Route to confirm that CloudPayments may process the payment. * https://developers.cloudpayments.ru/#check * + * This route handles both the first widget payment and later subscription charges. + * The first widget payment sends signed Data with billing intent and optional promo. + * Later recurrent charges may arrive without Data; in that case we resolve the + * workspace and current plan by SubscriptionId and validate the amount against + * the full plan price. + * * @param req - cloudpayments request with payment details * @param res - check result code */ @@ -141,7 +148,7 @@ export default class CloudPaymentsWebhooks { let workspace: WorkspaceModel; let member: ConfirmedMemberDBScheme; - let plan: PlanDBScheme; + let plan: PlanModel; let planId: string; const { workspaceId, userId, tariffPlanId } = data; @@ -160,12 +167,48 @@ export default class CloudPaymentsWebhooks { } const recurrentPaymentSettings = data.cloudPayments?.recurrent; + let promoPricing; /** - * The amount will be considered correct if it is equal to the cost of the tariff plan. - * Also, the cost will be correct if it is a payment to activate the subscription. + * Revalidate promo before accepting payment. + * + * Amount check uses server-side pricing; usage is recorded later in /pay + * after workspace plan is updated successfully. */ - const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; + if (data.promo && !data.isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(context.factories); + + promoPricing = await promoCodeService.getPricingForPromoCodeId( + data.promo.id, + data.userId, + data.workspaceId, + plan + ); + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo code is invalid: ${error.toString()}`, body); + + return; + } + } + + /** + * Validates payment amount from CloudPayments against expected charge. + * + * expectedAmount: + * - with promo: final price recalculated on the server by promo id + * - without promo: full selected plan monthly charge + * + * isRightAmount is true when: + * 1) body.Amount equals expectedAmount — regular one-time payment (with or without promo) + * 2) no promo and recurrent.startDate is set — subscription is created with a deferred first charge; + * current payment can be a card-link/auth amount (for example 1 RUB) while recurrent.amount + * stores the real plan price for future charges + */ + const expectedAmount = promoPricing?.finalAmount ?? plan.monthlyCharge; + const isRightAmount = +body.Amount === expectedAmount || (!data.promo && recurrentPaymentSettings?.startDate); if (!isRightAmount) { this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); @@ -173,6 +216,32 @@ export default class CloudPaymentsWebhooks { return; } + /** + * Validates recurrent.amount for the first subscription payment when promo is applied. + * + * CloudPayments flows: + * 1. First charge via widget — body.Data is present, checksum may include promo.id and + * cloudPayments.recurrent.amount. Discount applies only to body.Amount (first charge). + * recurrent.amount must stay equal to full plan.monthlyCharge so later renewals bill full price. + * 2. Monthly renewals — body.Data is absent, getDataFromRequest() resolves workspace by + * SubscriptionId only, data.promo is undefined, this block is skipped, and amount must + * equal plan.monthlyCharge (see isRightAmount above). + */ + if ( + data.promo && + recurrentPaymentSettings?.amount !== undefined && + +recurrentPaymentSettings.amount !== plan.monthlyCharge + ) { + this.sendError( + res, + CheckCodes.WRONG_AMOUNT, + '[Billing / Check] Recurrent amount must equal full plan price when promo is applied', + body + ); + + return; + } + /** * Create business operation about creation of subscription */ @@ -303,6 +372,22 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo && !data.isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(req.context.factories); + + await promoCodeService.createUsage({ + promoCodeId: data.promo.id, + userId: data.userId, + workspaceId: workspace._id, + plan: tariffPlan, + utm: data.promo.utm, + }); + } catch (error) { + console.error('[Billing / Pay] Failed to record promo usage after plan change', error); + } + } + // let accountId = workspace.accountId; /* @@ -442,7 +527,7 @@ plan monthly charge: ${data.cloudPayments?.recurrent.amount} ${body.Currency}` */ const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined; - await this.sendReceipt(workspace, tariffPlan, userEmail); + await this.sendReceipt(workspace, tariffPlan, userEmail, +body.Amount); let messageText = ''; @@ -563,9 +648,15 @@ subscription id: ${body.SubscriptionId}`; } /** - * Route is executed if the status of the recurring payment subscription has been changed. + * Route executed when a CloudPayments subscription status changes. * https://developers.cloudpayments.ru/#recurrent * + * This notification is about the subscription entity, not a replacement for + * /check or /pay transaction notifications. CloudPayments identifies which + * charge number this is via SuccessfulTransactionsNumber and sends the + * subscription Id; our transaction handlers use that Id to find the workspace. + * Promo data is not expected here and is not applied to subscription renewals. + * * @param req - cloudpayments request with subscription details * @param res - result code */ @@ -751,7 +842,17 @@ status: ${body.Status}` } /** - * Parses request body and returns data from it + * Parses CloudPayments request body into the signed billing intent. + * + * First widget payments include Data.checksum generated by composePayment; the + * checksum is the only trusted source for workspaceId, tariffPlanId, userId, + * shouldSaveCard, and promo id. Unsigned widget Data fields must not override it. + * + * Recurrent subscription renewals usually do not include Data. For those + * requests CloudPayments sends SubscriptionId and AccountId, so we restore the + * workspace and current plan by SubscriptionId. Because there is no signed promo + * in this path, data.promo is intentionally absent: promo discounts are applied + * only to the first widget payment, while renewals are charged at full plan price. * * @param req - request with necessary data */ @@ -760,15 +861,34 @@ status: ${body.Status}` const body: CheckRequest | PayRequest | FailRequest = req.body; /** - * If Data is not presented in body means there is a recurring payment - * Data field is presented only in one-time payment requests or subscription initial request + * If Data is absent, this is a subscription renewal (or check/pay identified by SubscriptionId). + * Renewals do not carry promo: discount was applied only on the first widget payment. */ if (body.Data) { const parsedData = JSON.parse(body.Data || '{}') as WebhookData; + const checksumData = checksumService.parseAndVerifyChecksum(parsedData.checksum); + + /** + * Treat checksum as the source of truth for billing intent. + * + * Widget Data is client-controlled, so it must not override signed fields like + * workspaceId, tariffPlanId, userId, shouldSaveCard, or promo id. Only + * CloudPayments recurrent settings are accepted from Data because they are + * validated separately against server-side pricing in /check. + */ + if ('isCardLinkOperation' in checksumData) { + return { + ...checksumData, + tariffPlanId: '', + shouldSaveCard: false, + ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), + }; + } return { - ...checksumService.parseAndVerifyChecksum(parsedData.checksum), - ...parsedData, + ...checksumData, + ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), + isCardLinkOperation: false, }; } @@ -826,8 +946,9 @@ status: ${body.Status}` * @param workspace - workspace for which payment is made * @param tariff - paid tariff plan * @param userMail - user email address + * @param amount - actual paid amount */ - private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string): Promise { + private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string, amount = tariff.monthlyCharge): Promise { /** * A general tax that applies to all commercial activities * involving the production and distribution of goods and the provision of services @@ -836,9 +957,9 @@ status: ${body.Status}` const VALUE_ADDED_TAX = 0; const item: CustomerReceiptItem = { - amount: tariff.monthlyCharge, + amount, label: `${tariff.name} tariff plan`, - price: tariff.monthlyCharge, + price: amount, vat: VALUE_ADDED_TAX, quantity: 1, }; diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index fb554dd95..4960e68a6 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,3 +1,5 @@ +import type { Utm } from '@hawk.so/types'; + /** * Data for setting up recurring payments */ @@ -18,7 +20,8 @@ interface RecurrentPaymentSettings { startDate?: string; /** - * Recurring payment amount. + * Recurring payment amount for automatic subscription renewals. + * Keep it equal to the full plan price even when the first widget charge uses a promo. */ amount?: number; } @@ -35,6 +38,23 @@ interface CloudPaymentsSettings { recurrent: RecurrentPaymentSettings; } +/** + * Promo reference attached to the signed first payment request. + * Amounts are resolved on the server by promo id during check/pay. + * Recurrent renewals are restored by SubscriptionId and do not carry promo data. + */ +export interface PaymentPromoData { + /** + * Applied promo code id + */ + id: string; + + /** + * UTM parameters captured when promo was applied + */ + utm?: Utm; +} + export interface PaymentData { /** * Data for Cloudpayments needs @@ -56,6 +76,10 @@ export interface PaymentData { * If true, we will save user card */ shouldSaveCard: boolean; + /** + * Applied promo code reference + */ + promo?: PaymentPromoData; /** * True if this is card linking operation – charging minimal amount of money to validate card info */ diff --git a/src/directives/requireAdmin.ts b/src/directives/requireAdmin.ts index 453b161b3..3acb33189 100644 --- a/src/directives/requireAdmin.ts +++ b/src/directives/requireAdmin.ts @@ -80,6 +80,14 @@ export default function requireAdminDirective(directiveName = 'requireAdmin') { await checkByWorkspaceId(context, args.workspaceId); } + if (args.input?.workspaceId) { + await checkByWorkspaceId(context, args.input.workspaceId); + } + + if (args.projectId) { + await checkByProjectId(context, args.projectId); + } + if (args.input?.projectId) { await checkByProjectId(context, args.input.projectId); } diff --git a/src/index.ts b/src/index.ts index cb6f8d934..3c41d0c7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ import ReleasesFactory from './models/releasesFactory'; import RedisHelper from './redisHelper'; import { appendSsoRoutes } from './sso'; import { appendGitHubRoutes } from './integrations/github'; +import PromoCodesFactory from './models/promoCodesFactory'; +import PromoCodeUsagesFactory from './models/promoCodeUsagesFactory'; /** * Option to enable playground @@ -172,6 +174,12 @@ class HawkAPI { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const releasesFactory = new ReleasesFactory(mongo.databases.events!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const promoCodeUsagesFactory = new PromoCodeUsagesFactory(mongo.databases.hawk!); + return { usersFactory, workspacesFactory, @@ -179,6 +187,8 @@ class HawkAPI { plansFactory, businessOperationsFactory, releasesFactory, + promoCodesFactory, + promoCodeUsagesFactory, }; } diff --git a/src/models/promoCode.ts b/src/models/promoCode.ts new file mode 100644 index 000000000..b612618f8 --- /dev/null +++ b/src/models/promoCode.ts @@ -0,0 +1,66 @@ +import { Collection, ObjectId } from 'mongodb'; +import AbstractModel from './abstractModel'; +import { + PromoCodeBenefit, + PromoCodeDBScheme +} from '@hawk.so/types'; + +/** + * Model representing promo code settings. + */ +export default class PromoCodeModel extends AbstractModel implements PromoCodeDBScheme { + /** + * Promo code id. + */ + public _id!: ObjectId; + + /** + * Normalized promo code value. + */ + public value!: string; + + /** + * Benefit granted by this promo code. + */ + public benefit!: PromoCodeBenefit; + + /** + * Maximum successful usages count. + */ + public limit?: number; + + /** + * Expiration date. + */ + public expiresAt?: Date; + + /** + * Creation date. + */ + public createdAt!: Date; + + /** + * Last update date. + */ + public updatedAt!: Date; + + /** + * Creator id. + */ + public createdBy!: string; + + /** + * Model's collection. + */ + protected collection: Collection; + + /** + * Create PromoCode instance. + * + * @param promoCodeData - promo code data + */ + constructor(promoCodeData: PromoCodeDBScheme) { + super(promoCodeData); + this.collection = this.dbConnection.collection('promoCodes'); + } +} diff --git a/src/models/promoCodeUsage.ts b/src/models/promoCodeUsage.ts new file mode 100644 index 000000000..819ba10c7 --- /dev/null +++ b/src/models/promoCodeUsage.ts @@ -0,0 +1,81 @@ +import { Collection, ObjectId } from 'mongodb'; +import AbstractModel from './abstractModel'; +import { + PromoCodeBenefitType, + PromoCodeUsageDBScheme +} from '@hawk.so/types'; + +/** + * Model representing successful promo code application. + */ +export default class PromoCodeUsageModel extends AbstractModel implements PromoCodeUsageDBScheme { + /** + * Promo code usage id. + */ + public _id!: ObjectId; + + /** + * Applied promo code id. + */ + public promoCodeId!: ObjectId; + + /** + * User who applied promo code. + */ + public userId!: string; + + /** + * Workspace where promo code was applied. + */ + public workspaceId!: ObjectId; + + /** + * Plan to which promo was applied. + */ + public planId?: ObjectId; + + /** + * Benefit type at application time. + */ + public benefitType!: PromoCodeBenefitType; + + /** + * Price before promo. + */ + public originalAmount?: number; + + /** + * Price after promo. + */ + public finalAmount?: number; + + /** + * Actual discount amount. + */ + public discountAmount?: number; + + /** + * UTM parameters captured on apply. + */ + public utm?: PromoCodeUsageDBScheme['utm']; + + /** + * Application date. + */ + public appliedAt!: Date; + + /** + * Model's collection. + */ + protected collection: Collection; + + /** + * Create PromoCodeUsage instance. + * + * @param usageData - usage data + */ + constructor(usageData: PromoCodeUsageDBScheme) { + super(usageData); + this.collection = this.dbConnection.collection('promoCodeUsages'); + } +} diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts new file mode 100644 index 000000000..a7f35697a --- /dev/null +++ b/src/models/promoCodeUsagesFactory.ts @@ -0,0 +1,99 @@ +import AbstractModelFactory from './abstactModelFactory'; +import PromoCodeUsageModel from './promoCodeUsage'; +import { Collection, Db, ObjectId } from 'mongodb'; +import { PromoCodeUsageDBScheme } from '@hawk.so/types'; + +/** + * Input for creating promo usage with MongoDB driver ObjectId instances. + */ +export type PromoCodeUsageCreateInput = Omit< + PromoCodeUsageDBScheme, + '_id' | 'promoCodeId' | 'workspaceId' | 'planId' +> & { + promoCodeId: ObjectId; + workspaceId: ObjectId; + planId?: ObjectId; +}; + +/** + * Promo code usages factory to work with promoCodeUsages collection. + */ +export default class PromoCodeUsagesFactory extends AbstractModelFactory { + /** + * DataBase collection to work with. + */ + protected collection: Collection; + + /** + * Creates promo code usages factory instance. + * + * @param dbConnection - connection to DataBase + */ + constructor(dbConnection: Db) { + super(dbConnection, PromoCodeUsageModel); + this.collection = dbConnection.collection('promoCodeUsages'); + } + + /** + * Counts successful usages of a promo code. + * + * @param promoCodeId - promo code id + */ + public async countByPromoCodeId(promoCodeId: ObjectId): Promise { + return this.collection.countDocuments({ promoCodeId }); + } + + /** + * Finds successful usage by promo code and user. + * + * @param promoCodeId - promo code id + * @param userId - user id + */ + public async findByPromoCodeAndUser(promoCodeId: ObjectId, userId: string): Promise { + const usage = await this.collection.findOne({ + promoCodeId, + userId, + }); + + if (!usage) { + return null; + } + + return new PromoCodeUsageModel(usage); + } + + /** + * Finds successful usage by promo code and workspace. + * + * @param promoCodeId - promo code id + * @param workspaceId - workspace id + */ + public async findByPromoCodeAndWorkspace(promoCodeId: ObjectId, workspaceId: ObjectId): Promise { + const usage = await this.collection.findOne({ + promoCodeId, + workspaceId, + }); + + if (!usage) { + return null; + } + + return new PromoCodeUsageModel(usage); + } + + /** + * Creates successful promo code usage. + * + * @param usageData - promo code usage data + */ + public async create(usageData: PromoCodeUsageCreateInput): Promise { + const usage = { + _id: new ObjectId(), + ...usageData, + }; + + await this.collection.insertOne(usage as PromoCodeUsageDBScheme); + + return new PromoCodeUsageModel(usage as PromoCodeUsageDBScheme); + } +} diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts new file mode 100644 index 000000000..78570c0a5 --- /dev/null +++ b/src/models/promoCodesFactory.ts @@ -0,0 +1,33 @@ +import AbstractModelFactory from './abstactModelFactory'; +import PromoCodeModel from './promoCode'; +import { Collection, Db } from 'mongodb'; +import { PromoCodeDBScheme } from '@hawk.so/types'; + +/** + * Promo codes factory to work with promoCodes collection. + */ +export default class PromoCodesFactory extends AbstractModelFactory { + /** + * DataBase collection to work with. + */ + protected collection: Collection; + + /** + * Creates promo codes factory instance. + * + * @param dbConnection - connection to DataBase + */ + constructor(dbConnection: Db) { + super(dbConnection, PromoCodeModel); + this.collection = dbConnection.collection('promoCodes'); + } + + /** + * Finds promo code by normalized value. + * + * @param value - normalized promo code value + */ + public async findByValue(value: string): Promise { + return this.findOne({ value }); + } +} diff --git a/src/models/user.ts b/src/models/user.ts index 26c696db4..2ed99e1b0 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -7,6 +7,7 @@ import objectHasOnlyProps from '../utils/objectHasOnlyProps'; import { NotificationsChannelsDBScheme } from '../types/notification-channels'; import { BankCard, UserDBScheme } from '@hawk.so/types'; import { v4 as uuid } from 'uuid'; +import type { Utm } from '@hawk.so/types'; /** * Utility type for making specific fields optional @@ -139,7 +140,7 @@ export default class UserModel extends AbstractModel> /** * UTM parameters from signup - Data form where user went to sign up. Used for analytics purposes */ - public utm?: UserDBScheme['utm']; + public utm?: Utm; /** * External identities for SSO (keyed by workspaceId) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 8e1869f8e..1939002c1 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -3,6 +3,7 @@ import UserModel from './user'; import { Collection, Db, OptionalId } from 'mongodb'; import DataLoaders from '../dataLoaders'; import { UserDBScheme } from '@hawk.so/types'; +import type { Utm } from '@hawk.so/types'; /** * Users factory to work with User Model @@ -66,7 +67,7 @@ export default class UsersFactory extends AbstractModelFactory { const generatedPassword = password || (await UserModel.generatePassword()); const hashedPassword = await UserModel.hashPassword(generatedPassword); diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index ee973d221..2cd830890 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -5,13 +5,17 @@ import UserModel from '../models/user'; import { BusinessOperationPayloadType, PayloadOfDepositByUser, - PayloadOfWorkspacePlanPurchase + PayloadOfWorkspacePlanPurchase, + Utm } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; import { UserInputError } from 'apollo-server-express'; import cloudPaymentsApi, { CloudPaymentsJsonData } from '../utils/cloudPaymentsApi'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; +import PromoCodeService, { PromoCodeVerifyResult, PromoCodeError, PromoCodeErrorCode, buildPaymentPromoData } from '../services/promoCodeService'; +import type { PaymentPromoData } from '../billing/types/paymentData'; +import { sanitizeUtmParams } from '../utils/utm/utm'; /** * The amount we will debit to confirm the subscription. @@ -27,9 +31,34 @@ interface ComposePaymentArgs { workspaceId: string; tariffPlanId: string; shouldSaveCard?: boolean; + promoCode?: string; + promoUtm?: Utm; }; } +/** + * Input data for promo code verification mutation. + */ +interface VerifyPromoCodeArgs { + input: { + workspaceId: string; + value: string; + }; +} + +/** + * Converts internal promo errors to public GraphQL errors. + * + * @param error - error to convert + */ +function throwPromoCodeGraphQLError(error: unknown): never { + if (error instanceof PromoCodeError) { + throw new UserInputError(error.code); + } + + throw new UserInputError(PromoCodeErrorCode.VerifyFailed); +} + /** * Data for processing payment with saved card */ @@ -82,13 +111,19 @@ export default { ): Promise<{ invoiceId: string; plan: { id: string; name: string; monthlyCharge: number }; + chargeAmount: number; isCardLinkOperation: boolean; currency: string; checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; + promo?: { + originalAmount: number; + finalAmount: number; + }; }> { - const { workspaceId, tariffPlanId, shouldSaveCard } = input; + const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; + const promoUtm = sanitizeUtmParams(input.promoUtm); if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -126,6 +161,26 @@ export default { isCardLinkOperation = true; } + let tariffChargeAmount = plan.monthlyCharge; + let paymentPromoChecksum: PaymentPromoData | undefined; + let composePaymentPromo; + + if (promoCode && !isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(factories); + const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); + + tariffChargeAmount = pricing.finalAmount; + paymentPromoChecksum = buildPaymentPromoData(pricing.promoCode._id.toString(), promoUtm); + composePaymentPromo = { + originalAmount: pricing.originalAmount, + finalAmount: pricing.finalAmount, + }; + } catch (error) { + throwPromoCodeGraphQLError(error); + } + } + // Calculate next payment date const lastChargeDate = workspace.lastChargeDate ? new Date(workspace.lastChargeDate) : now; const nextPaymentDate = isCardLinkOperation ? new Date(lastChargeDate) : new Date(now); @@ -149,10 +204,13 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), + ...(paymentPromoChecksum ? { promo: paymentPromoChecksum } : {}), }; const checksum = await checksumService.generateChecksum(checksumData); + const chargeAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : tariffChargeAmount; + /** * Send info to Telegram (non-blocking) */ @@ -160,7 +218,7 @@ export default { .sendMessage(`👀 [Billing / Compose payment] card link operation: ${isCardLinkOperation} -amount: ${+plan.monthlyCharge} RUB +amount: ${chargeAmount} RUB last charge date: ${workspace.lastChargeDate?.toISOString()} next payment date: ${nextPaymentDate.toISOString()} workspace id: ${workspace._id.toString()} @@ -175,11 +233,13 @@ debug: ${Boolean(workspace.isDebug)}` name: plan.name, monthlyCharge: plan.monthlyCharge, }, + chargeAmount, isCardLinkOperation, currency: 'RUB', checksum, nextPaymentDate, cloudPaymentsPublicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', + promo: composePaymentPromo, }; }, }, @@ -252,6 +312,36 @@ debug: ${Boolean(workspace.isDebug)}` }, Mutation: { + /** + * Verifies promo code for workspace and returns benefit data for client-side pricing. + * + * Access check is handled by @requireAdmin on GraphQL schema. + * + * @param _obj - parent object + * @param input - promo code input + * @param user - current authorized user + * @param factories - factories for working with models + */ + async verifyPromoCode( + _obj: undefined, + { input }: VerifyPromoCodeArgs, + { user, factories }: ResolverContextWithUser + ): Promise { + const workspace = await factories.workspacesFactory.findById(input.workspaceId); + + if (!workspace) { + throw new UserInputError(PromoCodeErrorCode.Invalid); + } + + const promoCodeService = new PromoCodeService(factories); + + try { + return await promoCodeService.verifyPromoCode(input.value, user.id, input.workspaceId); + } catch (error) { + throwPromoCodeGraphQLError(error); + } + }, + /** * Mutation for processing payment via saved card * @@ -298,6 +388,7 @@ debug: ${Boolean(workspace.isDebug)}` recurrent: { interval, period: 1, + amount: plan.monthlyCharge, }, }; @@ -307,7 +398,6 @@ debug: ${Boolean(workspace.isDebug)}` */ if (!isTariffPlanExpired) { jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString(); - jsonData.cloudPayments.recurrent.amount = plan.monthlyCharge; } } diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 7cadb46af..7abb7f3b2 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -8,9 +8,9 @@ import { SenderWorkerTaskType } from '../types/userNotifications'; import { TaskPriorities, emailNotification } from '../utils/emailNotifications'; import isE2E from '../utils/isE2E'; import { dateFromObjectId } from '../utils/dates'; -import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; +import type { Utm, UserDBScheme } from '@hawk.so/types'; import { validateUtmParams } from '../utils/utm/utm'; /** @@ -43,7 +43,7 @@ export default { */ async signUp( _obj: undefined, - { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, + { email, utm }: { email: string; utm?: Utm }, { factories }: ResolverContextBase ): Promise { const validatedUtm = validateUtmParams(utm); diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts new file mode 100644 index 000000000..0cd7793cc --- /dev/null +++ b/src/services/promoCodeService.ts @@ -0,0 +1,441 @@ +import { ObjectId } from 'mongodb'; +import { PromoCodeBenefitType } from '@hawk.so/types'; +import PlanModel from '../models/plan'; +import PromoCodeModel from '../models/promoCode'; +import PromoCodeUsageModel from '../models/promoCodeUsage'; +import { ContextFactories } from '../types/graphql'; +import type { Utm } from '@hawk.so/types'; +import type { PaymentPromoData } from '../billing/types/paymentData'; +import { + calculatePromoCodePlanPrice +} from '../utils/promoCodePricing'; +import { sanitizeUtmParams } from '../utils/utm/utm'; + +const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; +const DEFAULT_MIN_FINAL_PRICE = 1; + +/** + * Public promo code errors returned to clients. + */ +export enum PromoCodeErrorCode { + Invalid = 'PROMO_CODE_INVALID', + LimitExceeded = 'PROMO_CODE_LIMIT_EXCEEDED', + VerifyFailed = 'PROMO_CODE_VERIFY_FAILED', +} + +/** + * Promo code error with safe public code. + */ +export class PromoCodeError extends Error { + /** + * Public error code. + */ + public readonly code: PromoCodeErrorCode; + + /** + * Creates promo code error. + * + * @param code - public error code + * @param message - internal message + */ + constructor(code: PromoCodeErrorCode, message: string = code) { + super(message); + this.code = code; + } +} + +/** + * Validated promo data for one selected plan. + */ +export interface PromoCodePricingResult { + /** + * Promo code model. + */ + promoCode: PromoCodeModel; + + /** + * Benefit type. + */ + benefitType: PromoCodeBenefitType; + + /** + * Plan price before promo. + */ + originalAmount: number; + + /** + * Plan price after promo. + */ + finalAmount: number; + + /** + * Actual discount in money. + */ + discountAmount: number; +} + +/** + * Validated promo code data returned after verification. + */ +export interface PromoCodeVerifyResult { + /** + * Normalized promo value. + */ + value: string; + + /** + * Benefit type. + */ + benefitType: PromoCodeBenefitType; + + /** + * Discount percent for percent promo. + */ + percent?: number; + + /** + * Fixed price amount. + */ + amount?: number; + + /** + * Minimum final price after percent discount. + */ + minFinalPrice?: number; + + /** + * Plan ids this promo can be applied to. + */ + applicablePlanIds?: string[]; +} + +/** + * UTM data stored with promo code usage. + */ +export type PromoCodeUtm = Utm; + +/** + * Normalizes promo code value before DB lookup. + * + * @param value - raw promo code value + * @returns normalized promo code value + */ +export function normalizePromoCodeValue(value: string): string { + return value.trim().toUpperCase(); +} + +/** + * Checks if promo value format is allowed. + * + * @param value - normalized promo code value + * @returns whether value has allowed promo code format + */ +function isAllowedPromoValue(value: string): boolean { + return Boolean(value) && PROMO_CODE_REGEXP.test(value); +} + +function isSupportedPromoCodeBenefitType(type: PromoCodeBenefitType): boolean { + return type === 'percent_discount' || type === 'fixed_price'; +} + +/** + * Rejects benefit types that are defined in schema but not implemented yet. + * + * @param benefit - promo benefit + */ +function assertSupportedBenefitType(benefit: PromoCodeModel['benefit']): void { + if (!isSupportedPromoCodeBenefitType(benefit.type)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); + } +} + +/** + * Validates static benefit structure. + * + * @param benefit - promo benefit + */ +function validateBenefitStructure(benefit: PromoCodeModel['benefit']): void { + switch (benefit.type) { + case 'percent_discount': + if (typeof benefit.percent !== 'number' || benefit.percent <= 0 || benefit.percent > 100) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Percent discount is invalid'); + } + + return; + + case 'fixed_price': + if (typeof benefit.amount !== 'number' || benefit.amount < DEFAULT_MIN_FINAL_PRICE) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Fixed price is invalid'); + } + + return; + + default: + if (!isSupportedPromoCodeBenefitType(benefit.type)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); + } + + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Unknown benefit type'); + } +} + +/** + * Builds promo reference stored in payment checksum. + * + * @param promoCodeId - applied promo code id + * @param utm - optional UTM data + * @returns promo reference for payment checksum + */ +export function buildPaymentPromoData(promoCodeId: string, utm?: Utm): PaymentPromoData { + const sanitizedUtm = sanitizeUtmParams(utm); + + return { + id: promoCodeId, + ...(sanitizedUtm ? { utm: sanitizedUtm } : {}), + }; +} + +/** + * Service with promo code validation and usage helpers. + */ +export default class PromoCodeService { + /** + * Factories used by promo code service. + */ + private readonly factories: ContextFactories; + + /** + * Creates promo code service. + * + * @param factories - context factories + */ + constructor(factories: ContextFactories) { + this.factories = factories; + } + + /** + * Finds and validates promo code against common limits. + * + * @param value - raw promo code value + * @param userId - user id + * @param workspaceId - workspace id + */ + public async getValidPromoCode(value: string, userId: string, workspaceId: string): Promise { + const normalizedValue = normalizePromoCodeValue(value); + + if (!isAllowedPromoValue(normalizedValue)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo value format is invalid'); + } + + const promoCode = await this.factories.promoCodesFactory.findByValue(normalizedValue); + + if (!promoCode) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + } + + await this.validateLoadedPromoCode(promoCode, userId, workspaceId); + + return promoCode; + } + + /** + * Validates promo code by id for one selected plan and returns final price. + * + * @param promoCodeId - promo code id + * @param userId - user id + * @param workspaceId - workspace id + * @param plan - selected plan + */ + public async getPricingForPromoCodeId( + promoCodeId: string, + userId: string, + workspaceId: string, + plan: PlanModel + ): Promise { + if (!ObjectId.isValid(promoCodeId)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code id is invalid'); + } + + const promoCode = await this.factories.promoCodesFactory.findOne({ _id: new ObjectId(promoCodeId) }); + + if (!promoCode) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + } + + await this.validateLoadedPromoCode(promoCode, userId, workspaceId); + + return this.buildPricingResult(promoCode, plan); + } + + /** + * Verifies promo code and returns benefit data for client-side price calculation. + * + * @param value - raw promo code value + * @param userId - user id + * @param workspaceId - workspace id + */ + public async verifyPromoCode(value: string, userId: string, workspaceId: string): Promise { + const promoCode = await this.getValidPromoCode(value, userId, workspaceId); + const benefit = promoCode.benefit; + + const result: PromoCodeVerifyResult = { + value: promoCode.value, + benefitType: benefit.type, + }; + + if (benefit.type === 'percent_discount') { + result.percent = benefit.percent; + + if (benefit.minFinalPrice !== undefined) { + result.minFinalPrice = benefit.minFinalPrice; + } + } + + if (benefit.type === 'fixed_price') { + result.amount = benefit.amount; + } + + if ( + (benefit.type === 'percent_discount' || benefit.type === 'fixed_price') && + benefit.applicablePlanIds?.length + ) { + result.applicablePlanIds = benefit.applicablePlanIds.map((planId): string => planId.toString()); + } + + return result; + } + + /** + * Validates promo code for one selected plan and returns final price. + * + * @param value - raw promo code value + * @param userId - user id + * @param workspaceId - workspace id + * @param plan - selected plan + */ + public async getPricingForPlan(value: string, userId: string, workspaceId: string, plan: PlanModel): Promise { + const promoCode = await this.getValidPromoCode(value, userId, workspaceId); + + return this.buildPricingResult(promoCode, plan); + } + + /** + * Creates usage after successful payment. + * + * Re-validates promo by id, resolves pricing for the selected plan, and stores usage. + * Unique indexes on promoCodeId + userId/workspaceId enforce one usage per user/workspace. + * + * @param params - usage creation params + * @returns created promo usage + */ + public async createUsage(params: { + promoCodeId: string; + userId: string; + workspaceId: ObjectId; + plan: PlanModel; + utm?: PromoCodeUtm; + }): Promise { + const promoPricing = await this.getPricingForPromoCodeId( + params.promoCodeId, + params.userId, + params.workspaceId.toString(), + params.plan + ); + + const utm = sanitizeUtmParams(params.utm); + + try { + return await this.factories.promoCodeUsagesFactory.create({ + promoCodeId: promoPricing.promoCode._id, + userId: params.userId, + workspaceId: params.workspaceId, + planId: params.plan._id, + benefitType: promoPricing.benefitType, + originalAmount: promoPricing.originalAmount, + finalAmount: promoPricing.finalAmount, + discountAmount: promoPricing.discountAmount, + appliedAt: new Date(), + ...(utm ? { utm } : {}), + }); + } catch (error) { + if ((error as { code?: number }).code === 11000) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo usage already exists'); + } + + throw error; + } + } + + /** + * Validates loaded promo code against limits and expiry. + * + * @param promoCode - promo code model + * @param userId - user id + * @param workspaceId - workspace id + */ + private async validateLoadedPromoCode( + promoCode: PromoCodeModel, + userId: string, + workspaceId: string + ): Promise { + if (promoCode.expiresAt && new Date() > new Date(promoCode.expiresAt)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); + } + + assertSupportedBenefitType(promoCode.benefit); + validateBenefitStructure(promoCode.benefit); + await this.validateUsageLimits(promoCode, userId, new ObjectId(workspaceId)); + } + + /** + * Builds pricing result for validated promo code and plan. + * + * @param promoCode - promo code model + * @param plan - selected plan + * @returns validated promo pricing for selected plan + */ + private buildPricingResult(promoCode: PromoCodeModel, plan: PlanModel): PromoCodePricingResult { + const benefit = promoCode.benefit; + + if (benefit.type !== 'percent_discount' && benefit.type !== 'fixed_price') { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); + } + + const price = calculatePromoCodePlanPrice(benefit, plan); + + if (!price.isApplicable) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not applicable to selected plan'); + } + + return { + promoCode, + benefitType: promoCode.benefit.type, + originalAmount: price.originalAmount, + finalAmount: price.finalAmount, + discountAmount: price.discountAmount, + }; + } + + /** + * Validates all usage limits. + * + * @param promoCode - promo code model + * @param userId - user id + * @param workspaceId - workspace id + */ + private async validateUsageLimits(promoCode: PromoCodeModel, userId: string, workspaceId: ObjectId): Promise { + const [totalUses, userUsage, workspaceUsage] = await Promise.all([ + this.factories.promoCodeUsagesFactory.countByPromoCodeId(promoCode._id), + this.factories.promoCodeUsagesFactory.findByPromoCodeAndUser(promoCode._id, userId), + this.factories.promoCodeUsagesFactory.findByPromoCodeAndWorkspace(promoCode._id, workspaceId), + ]); + + if (typeof promoCode.limit === 'number' && totalUses >= promoCode.limit) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo total limit exceeded'); + } + + if (userUsage || workspaceUsage) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo per user or workspace limit exceeded'); + } + } +} diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 7cf9197b1..c89effeda 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -49,7 +49,7 @@ type ComposePaymentPlanInfo { name: String! """ - Monthly charge for plan + Monthly charge for plan (full tariff price) """ monthlyCharge: Int! } @@ -235,6 +235,91 @@ input ComposePaymentInput { Whether card should be saved for future recurrent payments """ shouldSaveCard: Boolean + + """ + Promo code value entered by user + """ + promoCode: String + + """ + UTM parameters captured when promo code was applied + """ + promoUtm: UtmInput +} + +""" +Input for promo code verification +""" +input VerifyPromoCodeInput { + """ + Workspace id for which promo code is applied + """ + workspaceId: ID! + + """ + Promo code value entered by user + """ + value: String! +} + +""" +Promo code benefit type +""" +enum PromoCodeBenefitType { + grant_plan + percent_discount + amount_discount + fixed_price +} + +""" +Verified promo code data for client-side price calculation +""" +type VerifyPromoCodeResponse { + """ + Normalized promo code value + """ + value: String! + + """ + Benefit type + """ + benefitType: PromoCodeBenefitType! + + """ + Discount percent for percent promos + """ + percent: Int + + """ + Fixed price amount + """ + amount: Int + + """ + Minimum final price after percent discount + """ + minFinalPrice: Int + + """ + Plan ids this promo can be applied to + """ + applicablePlanIds: [ID!] +} + +""" +Promo data returned with composePayment +""" +type ComposePaymentPromo { + """ + Plan price before promo + """ + originalAmount: Int! + + """ + Plan price after promo + """ + finalAmount: Int! } """ @@ -251,6 +336,11 @@ type ComposePaymentResponse { """ plan: ComposePaymentPlanInfo! + """ + Amount to charge for this payment (card validation amount, promo price, or full plan price) + """ + chargeAmount: Int! + """ True if only card linking validation payment is expected """ @@ -275,6 +365,11 @@ type ComposePaymentResponse { CloudPayments public id (merchant identifier for payment widget) """ cloudPaymentsPublicId: String! + + """ + Applied promo code data + """ + promo: ComposePaymentPromo } @@ -326,6 +421,11 @@ type PayWithCardResponse { } extend type Mutation { + """ + Verifies promo code for workspace admin and returns benefit data + """ + verifyPromoCode(input: VerifyPromoCodeInput!): VerifyPromoCodeResponse! @requireAdmin + """ Remove card """ diff --git a/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36b..640de5ae6 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -6,6 +6,8 @@ import ProjectsFactory from '../models/projectsFactory'; import PlansFactory from '../models/plansFactory'; import BusinessOperationsFactory from '../models/businessOperationsFactory'; import ReleasesFactory from '../models/releasesFactory'; +import PromoCodesFactory from '../models/promoCodesFactory'; +import PromoCodeUsagesFactory from '../models/promoCodeUsagesFactory'; /** * Resolver's Context argument @@ -92,6 +94,16 @@ export interface ContextFactories { * Releases factory for working with releases */ releasesFactory: ReleasesFactory; + + /** + * Promo codes factory for working with promo code settings + */ + promoCodesFactory: PromoCodesFactory; + + /** + * Promo code usages factory for working with successful applications + */ + promoCodeUsagesFactory: PromoCodeUsagesFactory; } /** diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 59601fb1c..38484fbf2 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,5 +1,5 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; +import type { PaymentPromoData } from '../billing/types/paymentData'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -24,6 +24,10 @@ interface PlanPurchaseChecksumData { * Next payment date */ nextPaymentDate: string; + /** + * Applied promo code data + */ + promo?: PaymentPromoData; } interface CardLinkChecksumData { @@ -84,6 +88,7 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, + promo: payload.promo, }; } } diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts new file mode 100644 index 000000000..c4a2f5c16 --- /dev/null +++ b/src/utils/promoCodePricing.ts @@ -0,0 +1,124 @@ +import type { + FixedPricePromoCodeBenefit, + PercentDiscountPromoCodeBenefit +} from '@hawk.so/types'; + +const DEFAULT_MIN_FINAL_PRICE = 1; + +/** + * Promo benefit shape used for price calculation. + */ +export type PromoCodePricingBenefit = + | PercentDiscountPromoCodeBenefit + | FixedPricePromoCodeBenefit; + +/** + * Minimal plan fields required for promo price calculation. + */ +export interface PromoCodePricingPlan { + id?: string; + _id?: { toString(): string }; + monthlyCharge: number; + isHidden?: boolean; +} + +/** + * Calculated promo price for one plan. + */ +export interface PromoCodePlanPrice { + planId: string; + isApplicable: boolean; + originalAmount: number; + finalAmount: number; + discountAmount: number; +} + +/** + * Returns plan id string for promo pricing. + * + * @param plan - plan with id or _id + */ +function getPlanId(plan: PromoCodePricingPlan): string { + if (plan.id) { + return plan.id; + } + + if (plan._id) { + return plan._id.toString(); + } + + throw new Error('Plan id is required for promo pricing'); +} + +function isPlanAvailableForPurchase(plan: PromoCodePricingPlan): boolean { + return plan.isHidden !== true; +} + +function isPlanApplicable(benefit: PromoCodePricingBenefit, plan: PromoCodePricingPlan): boolean { + if (!benefit.applicablePlanIds || benefit.applicablePlanIds.length === 0) { + return true; + } + + const planId = getPlanId(plan); + + return benefit.applicablePlanIds.some((applicablePlanId): boolean => applicablePlanId.toString() === planId); +} + +function isDiscountablePlan(plan: PromoCodePricingPlan): boolean { + return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); +} + +/** + * Calculates discounted price for one plan. + * + * Keep in sync with garage/src/utils/promoCodePricing.ts + * + * @param benefit - promo benefit + * @param plan - selected plan + */ +export function calculatePromoCodePlanPrice( + benefit: PromoCodePricingBenefit, + plan: PromoCodePricingPlan +): PromoCodePlanPrice { + const originalAmount = plan.monthlyCharge; + const planId = getPlanId(plan); + const result: PromoCodePlanPrice = { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + + if (!isDiscountablePlan(plan) || !isPlanApplicable(benefit, plan)) { + return result; + } + + switch (benefit.type) { + case 'percent_discount': { + const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; + const discountAmount = Math.floor(originalAmount * benefit.percent / 100); + const finalAmount = Math.max(originalAmount - discountAmount, minFinalPrice); + + if (finalAmount < originalAmount) { + result.isApplicable = true; + result.finalAmount = finalAmount; + result.discountAmount = originalAmount - finalAmount; + } + + return result; + } + + case 'fixed_price': + if (benefit.amount < originalAmount) { + result.isApplicable = true; + result.finalAmount = benefit.amount; + result.discountAmount = originalAmount - benefit.amount; + } + + return result; + + default: + return result; + } +} diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index c3845e01d..754ef6820 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -1,7 +1,19 @@ +import type { Utm } from '@hawk.so/types'; + /** * Valid UTM parameter keys */ -const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term']; +const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as const; + +/** + * Checks that passed key is supported UTM field. + * + * @param key - UTM object key + * @returns whether key is a valid UTM field + */ +function isValidUtmKey(key: string): key is keyof Utm { + return (VALID_UTM_KEYS as readonly string[]).includes(key); +} /** * Regular expression for valid UTM characters @@ -19,16 +31,16 @@ const MAX_UTM_VALUE_LENGTH = 50; * @param {Object} utm - UTM parameters to validate * @returns {Object} - filtered valid UTM parameters */ -export function validateUtmParams(utm: any): Record | undefined { +export function validateUtmParams(utm: unknown): Utm | undefined { if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { return undefined; } - const result: Record = {}; + const result: Utm = {}; for (const [key, value] of Object.entries(utm)) { // 1) Remove keys that are not VALID_UTM_KEYS - if (!VALID_UTM_KEYS.includes(key)) { + if (!isValidUtmKey(key)) { continue; } @@ -50,3 +62,18 @@ export function validateUtmParams(utm: any): Record | undefined return result; } + +/** + * Returns sanitized UTM params ready for storage, or undefined when nothing valid remains. + * + * @param utm - raw UTM parameters + */ +export function sanitizeUtmParams(utm: unknown): Utm | undefined { + const validated = validateUtmParams(utm); + + if (!validated || Object.keys(validated).length === 0) { + return undefined; + } + + return validated; +} diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts new file mode 100644 index 000000000..552746a0a --- /dev/null +++ b/test/billing/cloudpayments.test.ts @@ -0,0 +1,1195 @@ +import '../../src/env-test'; + +const cloudPaymentsClientMocks = { + createReceipt: jest.fn().mockResolvedValue(undefined), + cancelSubscription: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock('cloudpayments', () => ({ + ClientService: jest.fn().mockImplementation(() => ({ + getReceiptApi: jest.fn().mockReturnValue({ + createReceipt: (...args: unknown[]) => cloudPaymentsClientMocks.createReceipt(...args), + }), + getClientApi: jest.fn().mockReturnValue({ + cancelSubscription: (...args: unknown[]) => cloudPaymentsClientMocks.cancelSubscription(...args), + }), + })), + ReceiptTypes: { + Income: 'Income', + }, + TaxationSystem: { + SIMPLIFIED_INCOME: 'SIMPLIFIED_INCOME', + }, +})); + +jest.mock('../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { + cancelPayment: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn().mockReturnValue({}), + }, + }, +})); + +jest.mock('../../src/rabbitmq', () => ({ + publish: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/utils/telegram', () => ({ + sendMessage: jest.fn().mockResolvedValue(undefined), + TelegramBotURLs: { Money: 'money-url' }, +})); + +jest.mock('../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@hawk.so/nodejs', () => ({ + __esModule: true, + default: { + send: jest.fn(), + }, +})); + +import { ObjectId } from 'mongodb'; +import { + CardType, + Currency, + Interval, + OperationStatus, + OperationType, + ReasonCode, + SubscriptionStatus, +} from '../../src/billing/types/enums'; +import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; +import { CheckCodes, FailCodes, PayCodes, RecurrentCodes } from '../../src/billing/types'; +import checksumService from '../../src/utils/checksumService'; +import { publish } from '../../src/rabbitmq'; +import cloudPaymentsApi from '../../src/utils/cloudPaymentsApi'; +import sendNotification from '../../src/utils/personalNotifications'; +import { SenderWorkerTaskType } from '../../src/types/userNotifications'; +import { BusinessOperationStatus, BusinessOperationType } from '@hawk.so/types'; + +process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; +process.env.CLOUDPAYMENTS_PUBLIC_ID = 'public'; +process.env.CLOUDPAYMENTS_SECRET = 'secret'; +process.env.LEGAL_ENTITY_INN = '1234567890'; + +function createPromoCode(overrides: Record = {}) { + return { + _id: new ObjectId(), + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: new ObjectId().toString(), + ...overrides, + }; +} + +function createPlan(monthlyCharge = 1000) { + return { + _id: new ObjectId(), + name: 'Basic', + monthlyCharge, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; +} + +async function buildChecksumPayload(options: { + workspaceId: string; + userId: string; + planId: string; + promoId?: string; + cloudPayments?: Record; +}) { + const checksum = await checksumService.generateChecksum({ + workspaceId: options.workspaceId, + userId: options.userId, + tariffPlanId: options.planId, + shouldSaveCard: false, + nextPaymentDate: new Date().toISOString(), + ...(options.promoId ? { promo: { id: options.promoId } } : {}), + }); + + return JSON.stringify({ + checksum, + ...(options.cloudPayments ? { cloudPayments: options.cloudPayments } : {}), + }); +} + +async function buildCardLinkChecksumPayload(options: { + workspaceId: string; + userId: string; + cloudPayments?: Record; +}) { + const checksum = await checksumService.generateChecksum({ + isCardLinkOperation: true, + workspaceId: options.workspaceId, + userId: options.userId, + nextPaymentDate: new Date().toISOString(), + }); + + return JSON.stringify({ + checksum, + ...(options.cloudPayments ? { cloudPayments: options.cloudPayments } : {}), + }); +} + +function createCheckBody(transactionId: number, amount: string, data: string) { + return { + TransactionId: transactionId, + Amount: amount, + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data: data, + }; +} + +function createPayBody(transactionId: number, amount: string, data?: string, overrides: Record = {}) { + return { + TransactionId: transactionId, + Amount: amount, + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Token: 'token', + IssuerBankCountry: 'RU', + Data: data, + ...overrides, + }; +} + +function createWebhookContext(options: { + workspaceId: string; + userId: string; + plan: ReturnType; + promoCode?: ReturnType | null; + createUsageImpl?: jest.Mock; + subscriptionId?: string | null; + findBySubscriptionIdImpl?: jest.Mock; +}) { + const workspaceObjectId = new ObjectId(options.workspaceId); + const changePlan = jest.fn().mockResolvedValue(1); + const setSubscriptionId = jest.fn().mockResolvedValue(undefined); + const workspace = { + _id: workspaceObjectId, + name: 'Test Workspace', + tariffPlanId: options.plan._id, + subscriptionId: options.subscriptionId ?? null, + getMemberInfo: jest.fn().mockResolvedValue({ + _id: new ObjectId(options.userId), + userId: new ObjectId(options.userId), + isAdmin: true, + }), + changePlan, + setSubscriptionId, + }; + + const businessOperation = { + setStatus: jest.fn().mockResolvedValue(undefined), + }; + + const user = { + _id: new ObjectId(options.userId), + email: 'user@test.com', + saveNewBankCard: jest.fn().mockResolvedValue(undefined), + }; + + const createUsage = options.createUsageImpl ?? jest.fn().mockResolvedValue({ _id: new ObjectId() }); + const createBusinessOperation = jest.fn().mockResolvedValue(businessOperation); + + const context = { + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue(workspace), + findBySubscriptionId: options.findBySubscriptionIdImpl ?? jest.fn().mockResolvedValue(null), + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(options.plan), + }, + usersFactory: { + findById: jest.fn().mockResolvedValue(user), + }, + businessOperationsFactory: { + create: createBusinessOperation, + getBusinessOperationByTransactionId: jest.fn().mockResolvedValue(businessOperation), + }, + promoCodesFactory: { + findOne: jest.fn().mockResolvedValue(options.promoCode ?? null), + }, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create: createUsage, + }, + }, + }; + + return { + context, + workspace, + changePlan, + createUsage, + businessOperation, + createBusinessOperation, + user, + }; +} + +function createMockResponse() { + return { + json: jest.fn(), + }; +} + +describe('CloudPaymentsWebhooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDataFromRequest()', () => { + it('should trust checksum fields over unsigned widget Data fields', async () => { + const promoId = new ObjectId().toString(); + const unsignedPromoId = new ObjectId().toString(); + const checksum = await checksumService.generateChecksum({ + workspaceId: 'signed-workspace', + userId: 'signed-user', + tariffPlanId: 'signed-plan', + shouldSaveCard: false, + nextPaymentDate: new Date().toISOString(), + promo: { + id: promoId, + }, + }); + const webhooks = new CloudPaymentsWebhooks() as any; + + const data = await webhooks.getDataFromRequest({ + body: { + Data: JSON.stringify({ + checksum, + workspaceId: 'unsigned-workspace', + userId: 'unsigned-user', + tariffPlanId: 'unsigned-plan', + shouldSaveCard: true, + promo: { + id: unsignedPromoId, + }, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + }, + }, + }), + }, + }); + + expect(data).toMatchObject({ + workspaceId: 'signed-workspace', + userId: 'signed-user', + tariffPlanId: 'signed-plan', + shouldSaveCard: false, + promo: { + id: promoId, + }, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + }, + }, + isCardLinkOperation: false, + }); + }); + + it('should keep card-link checksum data when unsigned Data contains plan payment fields', async () => { + const checksum = await checksumService.generateChecksum({ + isCardLinkOperation: true, + workspaceId: 'signed-workspace', + userId: 'signed-user', + nextPaymentDate: new Date().toISOString(), + }); + const webhooks = new CloudPaymentsWebhooks() as any; + + const data = await webhooks.getDataFromRequest({ + body: { + Data: JSON.stringify({ + checksum, + tariffPlanId: 'unsigned-plan', + shouldSaveCard: true, + promo: { + id: new ObjectId().toString(), + }, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + }, + }, + }), + }, + }); + + expect(data).toMatchObject({ + workspaceId: 'signed-workspace', + userId: 'signed-user', + tariffPlanId: '', + shouldSaveCard: false, + isCardLinkOperation: true, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + }, + }, + }); + expect(data.promo).toBeUndefined(); + }); + + it('should restore subscription renewal data by SubscriptionId without promo', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, workspace } = createWebhookContext({ + workspaceId, + userId, + plan, + subscriptionId: 'subscription-id', + }); + + context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace); + + const data = await webhooks.getDataFromRequest({ + context, + body: { + SubscriptionId: 'subscription-id', + AccountId: userId, + }, + }); + + expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id'); + expect(data).toMatchObject({ + workspaceId, + userId, + tariffPlanId: plan._id.toString(), + shouldSaveCard: false, + isCardLinkOperation: false, + }); + expect(data.promo).toBeUndefined(); + }); + }); + + describe('check()', () => { + it('should accept card-link validation amount without changing plan', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, createBusinessOperation } = createWebhookContext({ + workspaceId, + userId, + plan, + }); + const res = createMockResponse(); + const Data = await buildCardLinkChecksumPayload({ + workspaceId, + userId, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: new Date().toISOString(), + }, + }, + }); + + await webhooks.check({ context, body: createCheckBody(1000, '1', Data) }, res); + + expect(createBusinessOperation).toHaveBeenCalledWith(expect.objectContaining({ + type: BusinessOperationType.CardLinkCharge, + status: BusinessOperationStatus.Pending, + })); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should validate amount against signed plan when unsigned Data tries to replace tariffPlanId', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const signedPlan = createPlan(1000); + const unsignedPlan = createPlan(500); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan: signedPlan, + }); + const res = createMockResponse(); + const checksum = await checksumService.generateChecksum({ + workspaceId, + userId, + tariffPlanId: signedPlan._id.toString(), + shouldSaveCard: false, + nextPaymentDate: new Date().toISOString(), + }); + const Data = JSON.stringify({ + checksum, + tariffPlanId: unsignedPlan._id.toString(), + }); + + context.factories.plansFactory.findById = jest.fn().mockImplementation((planId: string) => { + return Promise.resolve(planId === signedPlan._id.toString() ? signedPlan : unsignedPlan); + }); + + await webhooks.check({ context, body: createCheckBody(1001, '500', Data) }, res); + + expect(context.factories.plansFactory.findById).toHaveBeenCalledWith(signedPlan._id.toString()); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should reject wrong amount when promo id is in checksum', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + }, + }, + }); + + await webhooks.check({ + context, + body: { + TransactionId: 1001, + Amount: '1000', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should accept discounted amount and full recurrent amount when promo is applied', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: new Date().toISOString(), + }, + }, + }); + + await webhooks.check({ + context, + body: { + TransactionId: 1002, + Amount: '750', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should reject discounted recurrent amount when promo is applied', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 750, + }, + }, + }); + + await webhooks.check({ + context, + body: { + TransactionId: 1003, + Amount: '750', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should allow 1 RUB deferred charge only without promo', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: new Date().toISOString(), + }, + }, + }); + + await webhooks.check({ + context, + body: { + TransactionId: 1004, + Amount: '1', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should not allow 1 RUB deferred charge when promo is applied', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const { context } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: new Date().toISOString(), + }, + }, + }); + + await webhooks.check({ + context, + body: { + TransactionId: 1005, + Amount: '1', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Data, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should accept full plan amount when promo is not applied', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context } = createWebhookContext({ workspaceId, userId, plan }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + await webhooks.check({ context, body: createCheckBody(1006, '1000', Data) }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should accept full plan amount on subscription renewal check without promo in Data', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, workspace } = createWebhookContext({ + workspaceId, + userId, + plan, + subscriptionId: 'subscription-id', + }); + + context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace); + + const res = createMockResponse(); + + await webhooks.check({ + context, + body: { + ...createCheckBody(1010, '1000', ''), + SubscriptionId: 'subscription-id', + AccountId: userId, + Data: undefined, + }, + }, res); + + expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id'); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should reject wrong amount on subscription renewal check without Data', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, workspace } = createWebhookContext({ + workspaceId, + userId, + plan, + subscriptionId: 'subscription-id', + }); + + context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace); + + const res = createMockResponse(); + + await webhooks.check({ + context, + body: { + ...createCheckBody(1011, '999', ''), + SubscriptionId: 'subscription-id', + AccountId: userId, + Data: undefined, + }, + }, res); + + expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id'); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should reject wrong amount when promo is not applied', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context } = createWebhookContext({ workspaceId, userId, plan }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + await webhooks.check({ context, body: createCheckBody(1007, '999', Data) }, res); + + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + }); + + describe('pay()', () => { + it('should complete pay flow when createUsage fails after changePlan', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const createUsage = jest.fn().mockRejectedValue(new Error('usage failed')); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const { context, changePlan } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + createUsageImpl: createUsage, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + }); + + await webhooks.pay({ + context, + body: { + TransactionId: 2001, + Amount: '750', + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + Status: OperationStatus.COMPLETED, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + IssuerBankCountry: 'US', + Data, + }, + }, res); + + expect(changePlan).toHaveBeenCalledWith(plan._id); + expect(createUsage).toHaveBeenCalled(); + expect(publish).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Billing / Pay] Failed to record promo usage after plan change', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should record promo usage and complete payment when createUsage succeeds', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const promoCode = createPromoCode({ _id: new ObjectId() }); + const { context, changePlan, createUsage } = createWebhookContext({ + workspaceId, + userId, + plan, + promoCode, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ + workspaceId, + userId, + planId, + promoId: promoCode._id.toString(), + }); + + await webhooks.pay({ context, body: createPayBody(2002, '750', Data) }, res); + + expect(changePlan).toHaveBeenCalledWith(plan._id); + expect(createUsage).toHaveBeenCalledWith(expect.objectContaining({ + promoCodeId: promoCode._id, + userId, + workspaceId: expect.any(ObjectId), + planId: plan._id, + benefitType: 'percent_discount', + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + })); + expect(publish).toHaveBeenCalled(); + expect(sendNotification).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ type: SenderWorkerTaskType.PaymentSuccess }) + ); + expect(cloudPaymentsClientMocks.createReceipt).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should complete payment without promo usage when promo is absent', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context, changePlan, createUsage } = createWebhookContext({ + workspaceId, + userId, + plan, + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + await webhooks.pay({ context, body: createPayBody(2003, '1000', Data) }, res); + + expect(changePlan).toHaveBeenCalledWith(plan._id); + expect(createUsage).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should complete subscription renewal without Data and without promo usage', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, workspace, changePlan, createUsage } = createWebhookContext({ + workspaceId, + userId, + plan, + subscriptionId: 'subscription-id', + }); + + context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace); + + const res = createMockResponse(); + + await webhooks.pay({ + context, + body: createPayBody(2004, '1000', undefined, { + AccountId: userId, + Data: undefined, + SubscriptionId: 'subscription-id', + }), + }, res); + + expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id'); + expect(changePlan).toHaveBeenCalledWith(plan._id); + expect(createUsage).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should cancel old subscription when a new subscription id is received', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context, workspace } = createWebhookContext({ + workspaceId, + userId, + plan, + subscriptionId: 'old-subscription', + }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + await webhooks.pay({ + context, + body: createPayBody(2005, '1000', Data, { SubscriptionId: 'new-subscription' }), + }, res); + + expect(cloudPaymentsClientMocks.cancelSubscription).toHaveBeenCalledWith({ Id: 'old-subscription' }); + expect(workspace.setSubscriptionId).toHaveBeenCalledWith('new-subscription'); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should refund card-link charge and skip plan change', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const { context, changePlan, createBusinessOperation } = createWebhookContext({ + workspaceId, + userId, + plan, + }); + const res = createMockResponse(); + const Data = await buildCardLinkChecksumPayload({ + workspaceId, + userId, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: new Date().toISOString(), + }, + }, + }); + + await webhooks.pay({ context, body: createPayBody(2006, '1', Data) }, res); + + expect(changePlan).not.toHaveBeenCalled(); + expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2006); + expect(createBusinessOperation).toHaveBeenCalledWith(expect.objectContaining({ + type: BusinessOperationType.CardLinkRefund, + status: BusinessOperationStatus.Confirmed, + })); + expect(publish).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should fail pay flow when limiter task publish fails', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context } = createWebhookContext({ workspaceId, userId, plan }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + (publish as jest.Mock).mockRejectedValueOnce(new Error('rabbit down')); + + await webhooks.pay({ context, body: createPayBody(2007, '1000', Data) }, res); + + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should fail pay flow when payment success notification fails', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context } = createWebhookContext({ workspaceId, userId, plan }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + (sendNotification as jest.Mock).mockRejectedValueOnce(new Error('notify failed')); + + await webhooks.pay({ context, body: createPayBody(2008, '1000', Data) }, res); + + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + }); + + describe('fail()', () => { + it('should reject business operation and notify user about failed payment', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const planId = plan._id.toString(); + const { context, businessOperation } = createWebhookContext({ workspaceId, userId, plan }); + const res = createMockResponse(); + const Data = await buildChecksumPayload({ workspaceId, userId, planId }); + + await webhooks.fail({ + context, + body: { + TransactionId: 3001, + Amount: 1000, + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Reason: 'DoNotHonor', + ReasonCode: ReasonCode.DO_NOT_HONOR, + Data, + }, + }, res); + + expect(businessOperation.setStatus).toHaveBeenCalledWith(BusinessOperationStatus.Rejected); + expect(sendNotification).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ type: SenderWorkerTaskType.PaymentFailed }) + ); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); + + it('should reject fail webhook when checksum data is invalid', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const res = createMockResponse(); + + await webhooks.fail({ + context: { factories: {} }, + body: { + TransactionId: 3002, + Amount: 1000, + Currency: Currency.RUB, + DateTime: new Date(), + TestMode: true, + OperationType: OperationType.PAYMENT, + CardType: CardType.VISA, + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Reason: 'DoNotHonor', + ReasonCode: ReasonCode.DO_NOT_HONOR, + Data: '{ invalid json', + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); + }); + + describe('recurrent()', () => { + const recurrentBody = { + AccountId: 'user-id', + Amount: '1000', + Currency: Currency.RUB, + Description: 'Subscription', + Email: 'user@test.com', + FailedTransactionsNumber: 0, + Id: 'subscription-id', + Interval: Interval.MONTH, + Period: 1, + RequireConfirmation: false, + StartDate: new Date().toISOString(), + SuccessfulTransactionsNumber: 1, + }; + + it('should clear subscription id when subscription is cancelled in CloudPayments', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + const plan = createPlan(1000); + const setSubscriptionId = jest.fn().mockResolvedValue(undefined); + const workspace = { + _id: new ObjectId(workspaceId), + subscriptionId: 'subscription-id', + setSubscriptionId, + }; + const res = createMockResponse(); + + await webhooks.recurrent({ + context: { + factories: { + workspacesFactory: { + findBySubscriptionId: jest.fn().mockResolvedValue(workspace), + }, + }, + }, + body: { + ...recurrentBody, + Status: SubscriptionStatus.CANCELLED, + }, + }, res); + + expect(setSubscriptionId).toHaveBeenCalledWith(null); + expect(res.json).toHaveBeenCalledWith({ code: RecurrentCodes.SUCCESS }); + }); + + it('should succeed when cancelled subscription is already detached from workspace', async () => { + const webhooks = new CloudPaymentsWebhooks() as any; + const res = createMockResponse(); + + await webhooks.recurrent({ + context: { + factories: { + workspacesFactory: { + findBySubscriptionId: jest.fn().mockResolvedValue(null), + }, + }, + }, + body: { + ...recurrentBody, + Status: SubscriptionStatus.REJECTED, + }, + }, res); + + expect(res.json).toHaveBeenCalledWith({ code: RecurrentCodes.SUCCESS }); + }); + }); +}); diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts new file mode 100644 index 000000000..c59e71589 --- /dev/null +++ b/test/directives/requireAdmin.test.ts @@ -0,0 +1,106 @@ +import '../../src/env-test'; + +import { graphql } from 'graphql'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { ForbiddenError } from 'apollo-server-express'; +import requireAdminDirective from '../../src/directives/requireAdmin'; +import { ObjectId } from 'mongodb'; + +const { requireAdminDirectiveTypeDefs, requireAdminDirectiveTransformer } = requireAdminDirective(); + +const typeDefs = ` + ${requireAdminDirectiveTypeDefs} + + type Query { + verifyPromoCode(input: VerifyPromoCodeInput!): String! @requireAdmin + } + + input VerifyPromoCodeInput { + workspaceId: ID! + value: String! + } +`; + +const resolvers = { + Query: { + verifyPromoCode: (): string => 'ok', + }, +}; + +let schema = makeExecutableSchema({ typeDefs, resolvers }); + +schema = requireAdminDirectiveTransformer(schema); + +function createContext(options: { isAdmin: boolean }) { + const workspaceId = new ObjectId().toString(); + const userId = new ObjectId().toString(); + + return { + user: { + id: userId, + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue({ + _id: new ObjectId(workspaceId), + getMemberInfo: jest.fn().mockResolvedValue({ + userId: new ObjectId(userId), + isAdmin: options.isAdmin, + }), + }), + }, + projectsFactory: { + findById: jest.fn(), + }, + }, + workspaceId, + }; +} + +describe('requireAdmin directive', () => { + it('should allow mutation when user is workspace admin via input.workspaceId', async () => { + const context = createContext({ isAdmin: true }); + + const result = await graphql({ + schema, + source: ` + query VerifyPromoCode($input: VerifyPromoCodeInput!) { + verifyPromoCode(input: $input) + } + `, + variableValues: { + input: { + workspaceId: context.workspaceId, + value: 'PROMO', + }, + }, + contextValue: context, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.verifyPromoCode).toBe('ok'); + }); + + it('should reject mutation when user is not workspace admin via input.workspaceId', async () => { + const context = createContext({ isAdmin: false }); + + const result = await graphql({ + schema, + source: ` + query VerifyPromoCode($input: VerifyPromoCodeInput!) { + verifyPromoCode(input: $input) + } + `, + variableValues: { + input: { + workspaceId: context.workspaceId, + value: 'PROMO', + }, + }, + contextValue: context, + }); + + expect(result.errors?.[0]?.originalError).toBeInstanceOf(ForbiddenError); + }); +}); diff --git a/test/integration/cases/billing/promo.test.ts b/test/integration/cases/billing/promo.test.ts new file mode 100644 index 000000000..5ba035444 --- /dev/null +++ b/test/integration/cases/billing/promo.test.ts @@ -0,0 +1,275 @@ +import { apiInstance } from '../../utils'; +import { CheckCodes, CheckRequest, PayCodes, PayRequest } from '../../../../src/billing/types'; +import { CardType, Currency, OperationStatus, OperationType } from '../../../../src/billing/types/enums'; +import { Collection, Db, ObjectId } from 'mongodb'; +import { + BusinessOperationDBScheme, + BusinessOperationStatus, + BusinessOperationType, + ConfirmedMemberDBScheme, + PlanDBScheme, + PromoCodeDBScheme, + PromoCodeUsageDBScheme, + UserDBScheme, + WorkspaceDBScheme, +} from '@hawk.so/types'; +import checksumService from '../../../../src/utils/checksumService'; +import { mainRequest, transactionId } from '../../billingMocks'; +import type { Global } from '@jest/types'; + +declare var global: Global.Global; + +/** Plan price before promo (RUB) */ +const PLAN_MONTHLY_CHARGE = 1000; + +/** Expected charge after 25% promo discount (RUB) */ +const PROMO_DISCOUNTED_CHARGE = 750; + +describe('Promo billing webhooks', () => { + let accountsDb: Db; + let businessOperationsCollection: Collection; + let workspacesCollection: Collection; + let plansCollection: Collection>; + let usersCollection: Collection>; + let promoCodesCollection: Collection>; + let promoCodeUsagesCollection: Collection>; + + let workspace: WorkspaceDBScheme; + let admin: UserDBScheme; + let planToChange: PlanDBScheme; + let promoCode: PromoCodeDBScheme; + + beforeAll(async () => { + accountsDb = await global.mongoClient.db('hawk'); + + workspacesCollection = accountsDb.collection('workspaces'); + usersCollection = accountsDb.collection>('users'); + plansCollection = accountsDb.collection>('plans'); + businessOperationsCollection = accountsDb.collection('businessOperations'); + promoCodesCollection = accountsDb.collection>('promoCodes'); + promoCodeUsagesCollection = accountsDb.collection>('promoCodeUsages'); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + }); + + /** + * Insert a document and load the persisted record. + * Throws if insert or read fails — keeps fixture setup explicit in tests. + */ + async function insertAndLoad( + collection: Collection, + document: Omit, + errorMessage: string + ): Promise { + const insertedId = (await collection.insertOne(document)).insertedId; + const result = await collection.findOne({ _id: insertedId }); + + if (!result) { + throw new Error(errorMessage); + } + + return result as unknown as R; + } + + /** + * Seed workspace, admin, target plan and promo code for promo billing tests. + * + * Workspace starts on a cheap current plan; payment targets `planToChange` (1000 RUB). + * Promo `SAVE25` gives 25% off → expected first charge is 750 RUB. + * Admin is added to workspace team so check/pay webhooks pass membership checks. + */ + async function seedPromoBillingFixtures(): Promise { + const currentPlanId = (await plansCollection.insertOne({ + name: 'CurrentTestPlan', + monthlyCharge: 10, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + })).insertedId; + + workspace = await insertAndLoad( + workspacesCollection, + { + name: 'PromoBillingTest', + accountId: '123', + tariffPlanId: currentPlanId, + } as Omit, + 'Failed to create workspace' + ); + + admin = await insertAndLoad( + usersCollection, + { email: 'admin@promo-billing.test' }, + 'Failed to create admin' + ); + + planToChange = await insertAndLoad( + plansCollection, + { + name: 'PromoBasic', + monthlyCharge: PLAN_MONTHLY_CHARGE, + monthlyChargeCurrency: 'RUB', + eventsLimit: 10000, + isDefault: false, + }, + 'Failed to create planToChange' + ); + + promoCode = await insertAndLoad( + promoCodesCollection, + { + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: admin._id.toString(), + }, + 'Failed to create promo code' + ); + + const team = accountsDb.collection>(`team:${workspace._id.toString()}`); + + await team.insertOne({ + userId: admin._id, + isAdmin: true, + }); + } + + /** + * Checksum from composePayment with promo id embedded. + * CloudPayments check/pay handlers revalidate amount against this promo. + */ + async function buildPromoChecksum(): Promise { + return checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: admin._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + nextPaymentDate: new Date().toISOString(), + promo: { + id: promoCode._id.toString(), + }, + }); + } + + /** + * Build /billing/check request with promo checksum and recurrent subscription metadata. + */ + async function buildPromoCheckRequest(chargeAmount: number): Promise { + return { + ...mainRequest, + Amount: chargeAmount.toString(), + Currency: Currency.RUB, + Data: JSON.stringify({ + checksum: await buildPromoChecksum(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: PLAN_MONTHLY_CHARGE, + }, + }, + }), + }; + } + + beforeEach(async () => { + await seedPromoBillingFixtures(); + }); + + describe('/billing/check', () => { + describe('with promo code', () => { + it('should accept discounted charge amount and create pending business operation', async () => { + const apiResponse = await apiInstance.post('/billing/check', await buildPromoCheckRequest(PROMO_DISCOUNTED_CHARGE)); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); + /** + * /billing/check only validates payment and registers intent. + * Business operation stays Pending until /billing/pay confirms the charge. + */ + expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + it('should return WRONG_AMOUNT when charge equals full plan price instead of promo discount', async () => { + const apiResponse = await apiInstance.post('/billing/check', await buildPromoCheckRequest(PLAN_MONTHLY_CHARGE)); + + expect(apiResponse.data.code).toBe(CheckCodes.WRONG_AMOUNT); + }); + }); + }); + + describe('/billing/pay', () => { + describe('with promo code', () => { + let validPayRequestData: PayRequest; + + beforeEach(async () => { + /** + * /billing/pay expects check webhook to have already created a Pending operation + * for the same transactionId — mirrors real CloudPayments two-step flow. + */ + await businessOperationsCollection.insertOne({ + transactionId: transactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + dtCreated: new Date(), + payload: { + workspaceId: workspace._id, + amount: PROMO_DISCOUNTED_CHARGE * 100, + currency: Currency.RUB, + userId: admin._id, + tariffPlanId: planToChange._id, + }, + }); + + validPayRequestData = { + Amount: PROMO_DISCOUNTED_CHARGE.toString(), + CardExpDate: '06/25', + CardFirstSix: '578946', + CardLastFour: '5367', + CardType: CardType.VISA, + Currency: Currency.RUB, + DateTime: new Date(), + GatewayName: 'CodeX bank', + OperationType: OperationType.PAYMENT, + Status: OperationStatus.COMPLETED, + TestMode: false, + TotalFee: 0, + TransactionId: transactionId, + Token: '123123', + IssuerBankCountry: 'US', + Data: JSON.stringify({ + checksum: await buildPromoChecksum(), + }), + }; + }); + + it('should change plan and record promo usage after successful payment', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + const promoUsage = await promoCodeUsagesCollection.findOne({ promoCodeId: promoCode._id }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.tariffPlanId.toString()).toBe(planToChange._id.toString()); + expect(promoUsage).toMatchObject({ + promoCodeId: promoCode._id, + userId: admin._id.toString(), + workspaceId: workspace._id, + planId: planToChange._id, + benefitType: 'percent_discount', + originalAmount: PLAN_MONTHLY_CHARGE, + finalAmount: PROMO_DISCOUNTED_CHARGE, + discountAmount: PLAN_MONTHLY_CHARGE - PROMO_DISCOUNTED_CHARGE, + }); + }); + }); + }); +}); diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 03eacc94a..d7483ada9 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -238,6 +238,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -259,6 +261,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories, (req) => { @@ -280,6 +284,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -299,6 +305,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -321,6 +329,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -347,6 +357,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -381,6 +393,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -415,6 +429,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -470,6 +486,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -491,6 +509,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -514,6 +534,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -545,6 +567,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -578,6 +602,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -615,6 +641,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -653,6 +681,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -693,6 +723,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -728,6 +760,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -772,6 +806,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -830,6 +866,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -925,6 +963,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -995,6 +1035,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index e1ffc6a9f..047bc28a2 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -1,8 +1,29 @@ import '../../src/env-test'; + +jest.mock('../../src/rabbitmq', () => ({ + publish: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/utils/telegram', () => ({ + sendMessage: jest.fn().mockResolvedValue(undefined), + TelegramBotURLs: { Money: 'money-url' }, +})); + +jest.mock('../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { + payByToken: jest.fn(), + }, +})); + import { ObjectId } from 'mongodb'; import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; +import { UserInputError } from 'apollo-server-express'; import billingNewResolver from '../../src/resolvers/billingNew'; import { ResolverContextWithUser } from '../../src/types/graphql'; +import checksumService from '../../src/utils/checksumService'; +import { PromoCodeErrorCode } from '../../src/services/promoCodeService'; +import cloudPaymentsApi from '../../src/utils/cloudPaymentsApi'; // Set environment variables for test process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; @@ -82,6 +103,8 @@ function createComposePaymentTestSetup(options: { projectsFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; @@ -97,6 +120,100 @@ function createComposePaymentTestSetup(options: { }; } +/** + * Attaches promo code factories to resolver context. + */ +function withPromoFactories( + context: ResolverContextWithUser, + promoCode: Record | null, + options: { + totalUses?: number; + userUsage?: unknown; + workspaceUsage?: unknown; + } = {} +): void { + context.factories.promoCodesFactory = { + findByValue: jest.fn().mockResolvedValue(promoCode), + findOne: jest.fn().mockResolvedValue(promoCode), + } as any; + context.factories.promoCodeUsagesFactory = { + countByPromoCodeId: jest.fn().mockResolvedValue(options.totalUses ?? 0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(options.userUsage ?? null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(options.workspaceUsage ?? null), + create: jest.fn().mockResolvedValue({}), + } as any; +} + +/** + * Creates test data and mocks for verifyPromoCode tests. + */ +function createVerifyPromoCodeTestSetup(options: { + promoCode: Record | null; + grantPlan?: PlanDBScheme; +}): { + userId: string; + workspaceId: string; + mockContext: ResolverContextWithUser; + workspaceMock: Record; +} { + const userId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); + const planId = new ObjectId().toString(); + + const workspaceMock = { + _id: new ObjectId(workspaceId), + name: 'Test Workspace', + tariffPlanId: new ObjectId(planId), + getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }), + updatePlanHistory: jest.fn().mockResolvedValue(true), + updateLastChargeDate: jest.fn().mockResolvedValue(true), + changePlan: jest.fn().mockResolvedValue(1), + }; + + const defaultPlan: PlanDBScheme = { + _id: new ObjectId(planId), + name: 'Basic', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; + + const grantPlan = options.grantPlan || defaultPlan; + + const mockContext: ResolverContextWithUser = { + user: { + id: userId, + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue(workspaceMock), + } as any, + plansFactory: { + findAll: jest.fn().mockResolvedValue([defaultPlan]), + findById: jest.fn().mockResolvedValue(grantPlan), + } as any, + usersFactory: {} as any, + projectsFactory: {} as any, + businessOperationsFactory: {} as any, + releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, + }, + }; + + withPromoFactories(mockContext, options.promoCode); + + return { + userId, + workspaceId, + mockContext, + workspaceMock, + }; +} + describe('GraphQLBillingNew', () => { describe('composePayment', () => { it('should return isCardLinkOperation = false in case of expired tariff plan', async () => { @@ -125,6 +242,12 @@ describe('GraphQLBillingNew', () => { expect(result.isCardLinkOperation).toBe(false); + const checksumData = checksumService.parseAndVerifyChecksum(result.checksum); + + if ('tariffPlanId' in checksumData) { + expect(checksumData.promo).toBeUndefined(); + } + // Check that nextPaymentDate is one month from now const oneMonthFromNow = new Date(); @@ -199,5 +322,338 @@ describe('GraphQLBillingNew', () => { expect(nextPaymentDateStr).toBe(oneMonthFromNowStr); }); + + it('should apply valid promo code and store promo id in checksum', async () => { + const promoCodeId = new ObjectId(); + const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + isTariffPlanExpired: true, + isBlocked: false, + planMonthlyCharge: 1000, + }); + + withPromoFactories(mockContext, { + _id: promoCodeId, + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + }); + + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + shouldSaveCard: false, + promoCode: ' save25 ', + }, + }, + mockContext + ); + + expect(result.plan.monthlyCharge).toBe(1000); + expect(result.chargeAmount).toBe(750); + expect(result.promo).toMatchObject({ + originalAmount: 1000, + finalAmount: 750, + }); + + const checksumData = checksumService.parseAndVerifyChecksum(result.checksum); + + expect(checksumData).toMatchObject({ + promo: { id: promoCodeId.toString() }, + }); + }); + + it('should reject invalid promo code', async () => { + const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + isTariffPlanExpired: true, + isBlocked: false, + }); + + withPromoFactories(mockContext, null); + + await expect( + billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + promoCode: 'missing', + }, + }, + mockContext + ) + ).rejects.toMatchObject({ + message: PromoCodeErrorCode.Invalid, + }); + }); + + it('should ignore promo code for card link operation', async () => { + const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + isTariffPlanExpired: false, + isBlocked: false, + lastChargeDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + planMonthlyCharge: 1000, + }); + + withPromoFactories(mockContext, { + _id: new ObjectId(), + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + }); + + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + promoCode: 'save25', + }, + }, + mockContext + ); + + expect(result.isCardLinkOperation).toBe(true); + expect(result.plan.monthlyCharge).toBe(1000); + expect(result.chargeAmount).toBe(1); + expect(result.promo).toBeUndefined(); + }); + }); + + describe('verifyPromoCode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return benefit data without side effects', async () => { + const promoCodeId = new ObjectId(); + const { mockContext, workspaceId, workspaceMock } = createVerifyPromoCodeTestSetup({ + promoCode: { + _id: promoCodeId, + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + }, + }); + + const result = await billingNewResolver.Mutation.verifyPromoCode( + undefined, + { + input: { + workspaceId, + value: 'save25', + }, + }, + mockContext + ); + + expect(result).toMatchObject({ + value: 'SAVE25', + benefitType: 'percent_discount', + percent: 25, + }); + expect(workspaceMock.changePlan).not.toHaveBeenCalled(); + }); + + it('should reject unsupported grant_plan promo', async () => { + const promoCodeId = new ObjectId(); + const grantPlanId = new ObjectId(); + const { mockContext, workspaceId } = createVerifyPromoCodeTestSetup({ + promoCode: { + _id: promoCodeId, + value: 'GRANT', + benefit: { + type: 'grant_plan', + planId: grantPlanId, + }, + }, + grantPlan: { + _id: grantPlanId, + name: 'Pro', + monthlyCharge: 2000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 5000, + isDefault: false, + isHidden: false, + }, + }); + + await expect( + billingNewResolver.Mutation.verifyPromoCode( + undefined, + { + input: { + workspaceId, + value: 'grant', + }, + }, + mockContext + ) + ).rejects.toBeInstanceOf(UserInputError); + }); + + it('should reject unknown workspace', async () => { + const mockContext: ResolverContextWithUser = { + user: { + id: new ObjectId().toString(), + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue(null), + } as any, + plansFactory: {} as any, + usersFactory: {} as any, + projectsFactory: {} as any, + businessOperationsFactory: {} as any, + releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, + }, + }; + + await expect( + billingNewResolver.Mutation.verifyPromoCode( + undefined, + { + input: { + workspaceId: new ObjectId().toString(), + value: 'promo', + }, + }, + mockContext + ) + ).rejects.toBeInstanceOf(UserInputError); + }); + + it('should map promo validation errors to public codes', async () => { + const { mockContext, workspaceId } = createVerifyPromoCodeTestSetup({ + promoCode: null, + }); + + await expect( + billingNewResolver.Mutation.verifyPromoCode( + undefined, + { + input: { + workspaceId, + value: 'missing', + }, + }, + mockContext + ) + ).rejects.toMatchObject({ + message: PromoCodeErrorCode.Invalid, + }); + }); + }); + + describe('payWithCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should charge full plan amount for recurrent payment', async () => { + const userId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); + const cardId = 'card-1'; + const newPlanId = new ObjectId(); + const plan: PlanDBScheme = { + _id: newPlanId, + name: 'Test Plan', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; + const checksum = await checksumService.generateChecksum({ + workspaceId, + userId, + tariffPlanId: newPlanId.toString(), + shouldSaveCard: false, + nextPaymentDate: new Date().toISOString(), + }); + const dueDate = new Date(); + dueDate.setMonth(dueDate.getMonth() + 1); + + const mockContext: ResolverContextWithUser = { + user: { + id: userId, + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue({ + _id: new ObjectId(workspaceId), + tariffPlanId: new ObjectId(), + isDebug: false, + getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }), + isTariffPlanExpired: jest.fn().mockReturnValue(true), + getTariffPlanDueDate: jest.fn().mockReturnValue(dueDate), + }), + } as any, + plansFactory: { + findById: jest.fn().mockResolvedValue(plan), + } as any, + usersFactory: { + findById: jest.fn().mockResolvedValue({ + bankCards: [{ + id: cardId, + token: 'token-1', + }], + }), + } as any, + projectsFactory: {} as any, + businessOperationsFactory: { + getBusinessOperationByTransactionId: jest.fn().mockResolvedValue({ _id: new ObjectId() }), + } as any, + releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, + }, + }; + + (cloudPaymentsApi.payByToken as jest.Mock).mockResolvedValue({ + Model: { + TransactionId: 999, + }, + }); + + await billingNewResolver.Mutation.payWithCard( + undefined, + { + input: { + checksum, + cardId, + isRecurrent: true, + }, + }, + mockContext + ); + + expect(cloudPaymentsApi.payByToken).toHaveBeenCalledWith( + expect.objectContaining({ + Amount: 1000, + JsonData: expect.objectContaining({ + cloudPayments: expect.objectContaining({ + recurrent: expect.objectContaining({ + amount: 1000, + }), + }), + }), + }) + ); + }); }); }) diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index 00a4a9074..a01a05f66 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -125,6 +125,8 @@ function createMockContext(mockProject: ReturnType): R plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; } @@ -587,6 +589,8 @@ describe('Project Resolver - createProject', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts new file mode 100644 index 000000000..24b4b58de --- /dev/null +++ b/test/services/promoCodeService.test.ts @@ -0,0 +1,359 @@ +import { ObjectId } from 'mongodb'; +import PromoCodeService, { + normalizePromoCodeValue, + PromoCodeError, + PromoCodeErrorCode +} from '../../src/services/promoCodeService'; +import { calculatePromoCodePlanPrice } from '../../src/utils/promoCodePricing'; + +function createPlan(overrides: Record = {}) { + return { + _id: new ObjectId(), + name: 'Basic', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + ...overrides, + } as any; +} + +function createPromoCode(benefit: Record, overrides: Record = {}) { + return { + _id: new ObjectId(), + value: 'PROMO', + benefit, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: new ObjectId().toString(), + ...overrides, + } as any; +} + +function createService(promoCode: any, options: { + totalUses?: number; + userUsage?: unknown; + workspaceUsage?: unknown; + plans?: any[]; + plan?: any; +} = {}) { + const plan = options.plan || createPlan(); + + return new PromoCodeService({ + promoCodesFactory: { + findByValue: jest.fn().mockResolvedValue(promoCode), + findOne: jest.fn().mockResolvedValue(promoCode), + }, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(options.totalUses ?? 0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(options.userUsage ?? null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(options.workspaceUsage ?? null), + create: jest.fn().mockResolvedValue({ _id: new ObjectId() }), + }, + plansFactory: { + findAll: jest.fn().mockResolvedValue(options.plans || [plan]), + findById: jest.fn().mockResolvedValue(plan), + }, + } as any); +} + +async function expectPromoError(promise: Promise, code: PromoCodeErrorCode): Promise { + await expect(promise).rejects.toMatchObject({ + code, + } as PromoCodeError); +} + +describe('PromoCodeService', () => { + describe('normalizePromoCodeValue()', () => { + it('should trim and uppercase value before lookup', () => { + expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026'); + }); + }); + + describe('calculatePromoCodePlanPrice()', () => { + it('should apply percent discount with min final price cap', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'percent_discount', + percent: 90, + minFinalPrice: 200, + } as any, plan); + + expect(price).toMatchObject({ + isApplicable: true, + originalAmount: 1000, + finalAmount: 200, + discountAmount: 800, + }); + }); + + it('should apply fixed price promo', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'fixed_price', + amount: 299, + } as any, plan); + + expect(price.finalAmount).toBe(299); + expect(price.discountAmount).toBe(701); + }); + + it('should not apply discount when plan is outside applicablePlanIds', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'percent_discount', + percent: 50, + applicablePlanIds: [new ObjectId()], + } as any, plan); + + expect(price).toMatchObject({ + isApplicable: false, + finalAmount: 1000, + discountAmount: 0, + }); + }); + + it('should not apply discount promos to free plan', () => { + const plan = createPlan({ monthlyCharge: 0 }); + const price = calculatePromoCodePlanPrice({ + type: 'percent_discount', + percent: 20, + } as any, plan); + + expect(price).toMatchObject({ + isApplicable: false, + originalAmount: 0, + finalAmount: 0, + discountAmount: 0, + }); + }); + + it('should not apply fixed price promo when it is not cheaper than plan price', () => { + const plan = createPlan({ monthlyCharge: 100 }); + const price = calculatePromoCodePlanPrice({ + type: 'fixed_price', + amount: 100, + } as any, plan); + + expect(price).toMatchObject({ + isApplicable: false, + finalAmount: 100, + discountAmount: 0, + }); + }); + }); + + describe('verifyPromoCode()', () => { + it('should return benefit data for percent discount promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 25, + }); + const service = createService(promoCode, { plan }); + + const result = await service.verifyPromoCode(' promo ', new ObjectId().toString(), new ObjectId().toString()); + + expect(result).toMatchObject({ + value: 'PROMO', + benefitType: 'percent_discount', + percent: 25, + }); + expect(result.applicablePlanIds).toBeUndefined(); + }); + + it('should reject unknown promo code', async () => { + const service = createService(null); + + await expectPromoError(service.verifyPromoCode('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + + it('should reject expired promo code', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }, { + expiresAt: new Date(Date.now() - 1000), + }); + const service = createService(promoCode); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + + it('should reject total usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }, { + limit: 1, + }); + const service = createService(promoCode, { totalUses: 1 }); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('should reject user usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = createService(promoCode, { userUsage: {} }); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('should reject workspace usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = createService(promoCode, { workspaceUsage: {} }); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('should reject invalid benefit structure', async () => { + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 101, + }); + const service = createService(promoCode); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + }); + + describe('getPricingForPlan()', () => { + it('should reject unsupported amount_discount promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'amount_discount', + amount: 100, + }); + const service = createService(promoCode); + + await expectPromoError( + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid + ); + }); + + it('should reject unsupported grant_plan promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'grant_plan', + planId: new ObjectId(), + }); + const service = createService(promoCode); + + await expectPromoError( + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid + ); + }); + + it('should reject selected plan when promo is not applicable', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 10, + applicablePlanIds: [new ObjectId()], + }); + const service = createService(promoCode); + + await expectPromoError( + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid + ); + }); + }); + + describe('createUsage()', () => { + it('should resolve pricing by promo id and create usage record', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 25, + }); + const service = createService(promoCode, { plan }); + + const usage = await service.createUsage({ + promoCodeId: promoCode._id.toString(), + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + plan, + }); + + expect(usage).toMatchObject({ _id: expect.any(ObjectId) }); + }); + + it('should map duplicate usage creation to limit exceeded error', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = new PromoCodeService({ + promoCodesFactory: { + findOne: jest.fn().mockResolvedValue(promoCode), + }, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue({ code: 11000 }), + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(plan), + }, + } as any); + + await expectPromoError( + service.createUsage({ + promoCodeId: promoCode._id.toString(), + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + plan, + }), + PromoCodeErrorCode.LimitExceeded + ); + }); + + it('should reject second createUsage when insert returns duplicate key', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const create = jest.fn() + .mockResolvedValueOnce({ _id: new ObjectId() }) + .mockRejectedValueOnce({ code: 11000 }); + const service = new PromoCodeService({ + promoCodesFactory: { + findOne: jest.fn().mockResolvedValue(promoCode), + }, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create, + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(plan), + }, + } as any); + const usageParams = { + promoCodeId: promoCode._id.toString(), + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + plan, + }; + + await service.createUsage(usageParams); + + await expectPromoError(service.createUsage(usageParams), PromoCodeErrorCode.LimitExceeded); + expect(create).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 307c9777e..6a47b5de1 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -170,6 +170,8 @@ describe('SamlController', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; /** diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index fffbdb215..7643004fc 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -1,4 +1,4 @@ -import { validateUtmParams } from '../../src/utils/utm/utm'; +import { sanitizeUtmParams, validateUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { @@ -136,4 +136,19 @@ describe('UTM Utils', () => { }); }); }); + + describe('sanitizeUtmParams', () => { + it('should return undefined for invalid or empty UTM input', () => { + expect(sanitizeUtmParams(undefined)).toBeUndefined(); + expect(sanitizeUtmParams({ invalidKey: 'value' })).toBeUndefined(); + expect(sanitizeUtmParams({ source: 'bad@chars' })).toBeUndefined(); + }); + + it('should return sanitized UTM object for valid input', () => { + expect(sanitizeUtmParams({ source: 'google', medium: 'cpc' })).toEqual({ + source: 'google', + medium: 'cpc', + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 995f62677..0ba7c2ae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -510,10 +510,10 @@ dependencies: bson "^7.0.0" -"@hawk.so/types@^0.5.9": - version "0.5.9" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" - integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== +"@hawk.so/types@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.6.3.tgz#9416282e480528e07b86e61834a23c2f76cc5112" + integrity sha512-nFlIrcZFDhbseDy1Y9WVxWcymEr70yQCdBC337d1ZP0VgZpYj4rKTLy1ar8N9wTpZbw7utTyUI5jyW0RmNLQrA== dependencies: bson "^7.0.0"