From 9d1f51a15749075d5c9637a57bd29c8bfa12d1e8 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:37:43 +0300 Subject: [PATCH 01/43] feat(billing): implement promo code functionality and update related types --- package.json | 2 +- src/billing/cloudpayments.ts | 56 ++- src/billing/types/paymentData.ts | 34 ++ src/index.ts | 10 + src/models/promoCode.ts | 66 ++++ src/models/promoCodeUsage.ts | 81 +++++ src/models/promoCodeUsagesFactory.ts | 109 ++++++ src/models/promoCodesFactory.ts | 55 +++ src/resolvers/billingNew.ts | 137 ++++++- src/typeDefs/billing.ts | 141 ++++++++ src/types/graphql.ts | 12 + src/utils/checksumService.ts | 42 ++- src/utils/promoCodeService.ts | 511 +++++++++++++++++++++++++++ test/resolvers/billingNew.test.ts | 2 + test/utils/promoCodeService.test.ts | 251 +++++++++++++ yarn.lock | 8 +- 16 files changed, 1500 insertions(+), 17 deletions(-) create mode 100644 src/models/promoCode.ts create mode 100644 src/models/promoCodeUsage.ts create mode 100644 src/models/promoCodeUsagesFactory.ts create mode 100644 src/models/promoCodesFactory.ts create mode 100644 src/utils/promoCodeService.ts create mode 100644 test/utils/promoCodeService.test.ts diff --git a/package.json b/package.json index 2eb5eb56..ecfb3516 100644 --- a/package.json +++ b/package.json @@ -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.2", "@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 bcee09e0..3b82632c 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 '../utils/promoCodeService'; const PENNY_MULTIPLIER = 100; @@ -141,7 +142,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; @@ -161,11 +162,36 @@ export default class CloudPaymentsWebhooks { const recurrentPaymentSettings = data.cloudPayments?.recurrent; + if (data.promoCodeValue && !data.isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(context.factories); + const promoPricing = await promoCodeService.getPricingForPlan(data.promoCodeValue, data.userId, data.workspaceId, plan); + + if ( + promoPricing.promoCode._id.toString() !== data.promoCodeId || + promoPricing.finalAmount !== data.finalAmount || + promoPricing.originalAmount !== data.originalAmount || + promoPricing.discountAmount !== data.discountAmount + ) { + this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo code payment data does not match current promo calculation', body); + + return; + } + } 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; + } + } + /** * 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. */ - const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; + const expectedAmount = data.finalAmount ?? plan.monthlyCharge; + const isRightAmount = +body.Amount === expectedAmount || (!data.finalAmount && recurrentPaymentSettings?.startDate); if (!isRightAmount) { this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); @@ -295,6 +321,23 @@ export default class CloudPaymentsWebhooks { if (subscriptionId) { await workspace.setSubscriptionId(subscriptionId); } + + if (data.promoCodeValue && !data.isCardLinkOperation && data.benefitType) { + const promoCodeService = new PromoCodeService(req.context.factories); + const promoCode = await promoCodeService.getValidPromoCode(data.promoCodeValue, data.userId, data.workspaceId); + + await promoCodeService.createUsage({ + promoCode, + userId: data.userId, + workspaceId: workspace._id, + planId: tariffPlan._id, + benefitType: data.benefitType, + originalAmount: data.originalAmount, + finalAmount: data.finalAmount, + discountAmount: data.discountAmount, + utm: data.promoUtm, + }); + } } catch (e) { const error = e as Error; @@ -442,7 +485,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, data.finalAmount ?? tariffPlan.monthlyCharge); let messageText = ''; @@ -826,8 +869,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 +880,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 fb554dd9..f7382fa5 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -56,6 +56,40 @@ export interface PaymentData { * If true, we will save user card */ shouldSaveCard: boolean; + /** + * Applied promo code id + */ + promoCodeId?: string; + /** + * Applied promo code value + */ + promoCodeValue?: string; + /** + * Promo benefit type + */ + benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price'; + /** + * Plan price before promo + */ + originalAmount?: number; + /** + * Final price after promo + */ + finalAmount?: number; + /** + * Actual discount amount + */ + discountAmount?: number; + /** + * UTM parameters captured when promo was applied + */ + promoUtm?: { + source?: string; + medium?: string; + campaign?: string; + content?: string; + term?: string; + }; /** * True if this is card linking operation – charging minimal amount of money to validate card info */ diff --git a/src/index.ts b/src/index.ts index cb6f8d93..3c41d0c7 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 00000000..b612618f --- /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 00000000..819ba10c --- /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 00000000..6009d099 --- /dev/null +++ b/src/models/promoCodeUsagesFactory.ts @@ -0,0 +1,109 @@ +import AbstractModelFactory from './abstactModelFactory'; +import PromoCodeUsageModel from './promoCodeUsage'; +import { Collection, Db, ObjectId } from 'mongodb'; +import { PromoCodeUsageDBScheme } from '@hawk.so/types'; + +/** + * Promo code usages factory to work with promoCodeUsages collection. + */ +export default class PromoCodeUsagesFactory extends AbstractModelFactory { + /** + * DataBase collection to work with. + */ + protected collection: Collection; + + /** + * Index creation promise. + */ + private indexesPromise?: Promise; + + /** + * 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 { + await this.ensureIndexesOnce(); + + 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 { + await this.ensureIndexesOnce(); + + 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 { + await this.ensureIndexesOnce(); + + 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: Omit): Promise { + await this.ensureIndexesOnce(); + + const usage = { + _id: new ObjectId(), + ...usageData, + }; + + await this.collection.insertOne(usage); + + return new PromoCodeUsageModel(usage); + } + + /** + * Creates indexes required by promo usage limits. + */ + private async ensureIndexesOnce(): Promise { + this.indexesPromise ??= Promise.all([ + this.collection.createIndex({ promoCodeId: 1 }), + this.collection.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true }), + this.collection.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true }), + this.collection.createIndex({ workspaceId: 1 }), + this.collection.createIndex({ userId: 1 }), + ]).then(() => undefined); + + await this.indexesPromise; + } +} diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts new file mode 100644 index 00000000..652fb314 --- /dev/null +++ b/src/models/promoCodesFactory.ts @@ -0,0 +1,55 @@ +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; + + /** + * Index creation promise. + */ + private indexesPromise?: Promise; + + /** + * 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 { + await this.ensureIndexesOnce(); + + const promoCode = await this.collection.findOne({ value }); + + if (!promoCode) { + return null; + } + + return new PromoCodeModel(promoCode); + } + + /** + * Creates indexes required by promo codes lookups. + */ + private async ensureIndexesOnce(): Promise { + this.indexesPromise ??= this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined); + + await this.indexesPromise; + } +} diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index ee973d22..3b0d70a0 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -12,6 +12,8 @@ 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, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult } from '../utils/promoCodeService'; +import { publish } from '../rabbitmq'; /** * The amount we will debit to confirm the subscription. @@ -27,9 +29,47 @@ interface ComposePaymentArgs { workspaceId: string; tariffPlanId: string; shouldSaveCard?: boolean; + promoCode?: string; + promoUtm?: { + source?: string; + medium?: string; + campaign?: string; + content?: string; + term?: string; + }; }; } +/** + * Input data for promo code preview/apply mutation. + */ +interface PreviewPromoCodeArgs { + input: { + workspaceId: string; + value: string; + utm?: { + source?: string; + medium?: string; + campaign?: string; + content?: string; + term?: 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.ApplyFailed); +} + /** * Data for processing payment with saved card */ @@ -87,8 +127,12 @@ export default { checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; + promoCode?: string; + originalAmount?: number; + finalAmount?: number; + discountAmount?: number; }> { - const { workspaceId, tariffPlanId, shouldSaveCard } = input; + const { workspaceId, tariffPlanId, shouldSaveCard, promoCode, promoUtm } = input; if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -126,6 +170,29 @@ export default { isCardLinkOperation = true; } + let paymentAmount = plan.monthlyCharge; + let promoPaymentData; + + if (promoCode && !isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(factories); + const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); + + paymentAmount = pricing.finalAmount; + promoPaymentData = { + promoCodeId: pricing.promoCode._id.toString(), + promoCodeValue: pricing.promoCode.value, + benefitType: pricing.benefitType, + originalAmount: pricing.originalAmount, + finalAmount: pricing.finalAmount, + discountAmount: pricing.discountAmount, + promoUtm, + }; + } 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,6 +216,7 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), + ...promoPaymentData, }; const checksum = await checksumService.generateChecksum(checksumData); @@ -160,7 +228,7 @@ export default { .sendMessage(`👀 [Billing / Compose payment] card link operation: ${isCardLinkOperation} -amount: ${+plan.monthlyCharge} RUB +amount: ${+paymentAmount} RUB last charge date: ${workspace.lastChargeDate?.toISOString()} next payment date: ${nextPaymentDate.toISOString()} workspace id: ${workspace._id.toString()} @@ -173,13 +241,17 @@ debug: ${Boolean(workspace.isDebug)}` plan: { id: plan._id.toString(), name: plan.name, - monthlyCharge: plan.monthlyCharge, + monthlyCharge: paymentAmount, }, isCardLinkOperation, currency: 'RUB', checksum, nextPaymentDate, cloudPaymentsPublicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', + promoCode: promoPaymentData?.promoCodeValue, + originalAmount: promoPaymentData?.originalAmount, + finalAmount: promoPaymentData?.finalAmount, + discountAmount: promoPaymentData?.discountAmount, }; }, }, @@ -252,6 +324,59 @@ debug: ${Boolean(workspace.isDebug)}` }, Mutation: { + /** + * Preview discount promo or immediately apply grant_plan promo. + * + * @param _obj - parent object + * @param input - promo code input + * @param user - current authorized user + * @param factories - factories for working with models + */ + async previewPromoCode( + _obj: undefined, + { input }: PreviewPromoCodeArgs, + { user, factories }: ResolverContextWithUser + ): Promise { + const workspace = await factories.workspacesFactory.findById(input.workspaceId); + + if (!workspace) { + throw new UserInputError(PromoCodeErrorCode.Invalid); + } + + const member = await workspace.getMemberInfo(user.id); + + if (!member || !('isAdmin' in member) || !member.isAdmin) { + throw new UserInputError(PromoCodeErrorCode.Invalid); + } + + const promoCodeService = new PromoCodeService(factories); + + try { + const preview = await promoCodeService.preview(input.value, user.id, input.workspaceId); + + if (preview.benefitType !== 'grant_plan') { + return { + ...preview, + applied: false, + }; + } + + await promoCodeService.applyGrantPlan(input.value, user.id, workspace, input.utm); + + await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId: workspace._id.toString(), + })); + + return { + ...preview, + applied: true, + }; + } catch (error) { + throwPromoCodeGraphQLError(error); + } + }, + /** * Mutation for processing payment via saved card * @@ -278,6 +403,8 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Wrong checksum data'); } + const planPaymentAmount = paymentData.finalAmount ?? plan.monthlyCharge; + const token = fullUserInfo.bankCards?.find(card => card.id === args.input.cardId)?.token; if (!token) { @@ -307,11 +434,11 @@ debug: ${Boolean(workspace.isDebug)}` */ if (!isTariffPlanExpired) { jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString(); - jsonData.cloudPayments.recurrent.amount = plan.monthlyCharge; + jsonData.cloudPayments.recurrent.amount = planPaymentAmount; } } - let amount = plan.monthlyCharge; + let amount = planPaymentAmount; const isPaymentForCurrentTariffPlan = workspace.tariffPlanId.toString() === plan._id.toString(); diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 7cf9197b..df8d8b94 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -235,6 +235,122 @@ 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: PromoCodeUtmInput +} + +""" +Input for promo code preview/apply +""" +input PreviewPromoCodeInput { + """ + Workspace id for which promo code is applied + """ + workspaceId: ID! + + """ + Promo code value entered by user + """ + value: String! + + """ + UTM parameters captured when promo code was applied + """ + utm: PromoCodeUtmInput +} + +""" +UTM data stored with promo usage +""" +input PromoCodeUtmInput { + source: String + medium: String + campaign: String + content: String + term: String +} + +""" +Promo code benefit type +""" +enum PromoCodeBenefitType { + grant_plan + percent_discount + amount_discount + fixed_price +} + +""" +Calculated promo code price for a tariff plan +""" +type PromoCodePlanPrice { + """ + Plan id + """ + planId: ID! + + """ + Whether promo code can be applied to this plan + """ + isApplicable: Boolean! + + """ + Plan price before promo + """ + originalAmount: Int! + + """ + Plan price after promo + """ + finalAmount: Int! + + """ + Actual discount amount in money + """ + discountAmount: Int! +} + +""" +Promo code preview response +""" +type PreviewPromoCodeResponse { + """ + Normalized promo code value + """ + value: String! + + """ + Benefit type + """ + benefitType: PromoCodeBenefitType! + + """ + True if grant_plan promo was applied immediately + """ + applied: Boolean! + + """ + Discount percent for percent promos + """ + percent: Int + + """ + Discount or fixed price amount + """ + amount: Int + + """ + Calculated prices for visible plans + """ + plans: [PromoCodePlanPrice!]! } """ @@ -275,6 +391,26 @@ type ComposePaymentResponse { CloudPayments public id (merchant identifier for payment widget) """ cloudPaymentsPublicId: String! + + """ + Applied promo code value + """ + promoCode: String + + """ + Plan price before promo + """ + originalAmount: Int + + """ + Plan price after promo + """ + finalAmount: Int + + """ + Actual discount amount in money + """ + discountAmount: Int } @@ -326,6 +462,11 @@ type PayWithCardResponse { } extend type Mutation { + """ + Previews promo code discounts or applies grant_plan promo immediately + """ + previewPromoCode(input: PreviewPromoCodeInput!): PreviewPromoCodeResponse! + """ Remove card """ diff --git a/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36..640de5ae 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 59601fb1..a9304861 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,4 +1,3 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -24,6 +23,40 @@ interface PlanPurchaseChecksumData { * Next payment date */ nextPaymentDate: string; + /** + * Applied promo code id + */ + promoCodeId?: string; + /** + * Applied promo code value + */ + promoCodeValue?: string; + /** + * Promo benefit type + */ + benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price'; + /** + * Plan price before promo + */ + originalAmount?: number; + /** + * Final price after promo + */ + finalAmount?: number; + /** + * Actual discount amount + */ + discountAmount?: number; + /** + * UTM parameters captured when promo was applied + */ + promoUtm?: { + source?: string; + medium?: string; + campaign?: string; + content?: string; + term?: string; + }; } interface CardLinkChecksumData { @@ -84,6 +117,13 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, + promoCodeId: payload.promoCodeId, + promoCodeValue: payload.promoCodeValue, + benefitType: payload.benefitType, + originalAmount: payload.originalAmount, + finalAmount: payload.finalAmount, + discountAmount: payload.discountAmount, + promoUtm: payload.promoUtm, }; } } diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts new file mode 100644 index 00000000..eff93e6b --- /dev/null +++ b/src/utils/promoCodeService.ts @@ -0,0 +1,511 @@ +import { ObjectId } from 'mongodb'; +import { + PromoCodeBenefit, + PromoCodeBenefitType, + PromoCodeUsageDBScheme +} from '@hawk.so/types'; +import PlanModel from '../models/plan'; +import PromoCodeModel from '../models/promoCode'; +import WorkspaceModel from '../models/workspace'; +import { ContextFactories } from '../types/graphql'; + +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', + ApplyFailed = 'PROMO_CODE_APPLY_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; + } +} + +/** + * Price calculated for a plan after promo preview. + */ +export interface PromoCodePlanPrice { + /** + * Plan id. + */ + planId: string; + + /** + * Whether promo code can be applied to this plan. + */ + isApplicable: boolean; + + /** + * Plan price before promo. + */ + originalAmount: number; + + /** + * Plan price after promo. + */ + finalAmount: number; + + /** + * Actual discount in money. + */ + discountAmount: number; +} + +/** + * 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; +} + +/** + * Promo preview result for all plans. + */ +export interface PromoCodePreviewResult { + /** + * Normalized promo value. + */ + value: string; + + /** + * Benefit type. + */ + benefitType: PromoCodeBenefitType; + + /** + * Discount percent for percent promo. + */ + percent?: number; + + /** + * Discount amount or fixed price amount. + */ + amount?: number; + + /** + * Calculated price for each visible plan. + */ + plans: PromoCodePlanPrice[]; +} + +/** + * UTM data stored with promo code usage. + */ +export type PromoCodeUtm = PromoCodeUsageDBScheme['utm']; + +/** + * Normalizes promo code value before DB lookup. + * + * @param value - raw 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 + */ +function isAllowedPromoValue(value: string): boolean { + return Boolean(value) && PROMO_CODE_REGEXP.test(value); +} + +/** + * Returns whether plan is available for purchase/apply. + * + * @param plan - tariff plan + */ +function isPlanAvailable(plan: PlanModel): boolean { + return plan.isHidden !== true; +} + +/** + * Checks whether promo benefit is applicable to plan. + * + * @param benefit - promo benefit + * @param plan - selected plan + */ +function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { + if (benefit.type === 'grant_plan') { + return benefit.planId?.toString() === plan._id.toString(); + } + + if (!benefit.applicablePlanIds || benefit.applicablePlanIds.length === 0) { + return true; + } + + return benefit.applicablePlanIds.some((planId): boolean => planId.toString() === plan._id.toString()); +} + +/** + * Calculates discounted price for one plan. + * + * @param benefit - promo benefit + * @param plan - selected plan + */ +export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: PlanModel): PromoCodePlanPrice { + const originalAmount = plan.monthlyCharge; + const isApplicable = benefit.type !== 'grant_plan' && isPlanAvailable(plan) && isPlanApplicable(benefit, plan); + + if (!isApplicable) { + return { + planId: plan._id.toString(), + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + + 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); + + return { + planId: plan._id.toString(), + isApplicable: true, + originalAmount, + finalAmount, + discountAmount: originalAmount - finalAmount, + }; + } + + case 'amount_discount': { + const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; + const finalAmount = Math.max(originalAmount - benefit.amount, minFinalPrice); + + return { + planId: plan._id.toString(), + isApplicable: true, + originalAmount, + finalAmount, + discountAmount: originalAmount - finalAmount, + }; + } + + case 'fixed_price': + return { + planId: plan._id.toString(), + isApplicable: true, + originalAmount, + finalAmount: benefit.amount, + discountAmount: Math.max(originalAmount - benefit.amount, 0), + }; + + default: + return { + planId: plan._id.toString(), + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } +} + +/** + * Validates static benefit structure. + * + * @param benefit - promo benefit + */ +function validateBenefitStructure(benefit: PromoCodeBenefit): void { + switch (benefit?.type) { + case 'grant_plan': + if (!benefit.planId) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan id is missing'); + } + return; + + 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 'amount_discount': + if (typeof benefit.amount !== 'number' || benefit.amount <= 0) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Amount 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: + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Unknown benefit type'); + } +} + +/** + * 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'); + } + + if (promoCode.expiresAt && new Date() > new Date(promoCode.expiresAt)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); + } + + validateBenefitStructure(promoCode.benefit); + await this.validateUsageLimits(promoCode, userId, new ObjectId(workspaceId)); + + return promoCode; + } + + /** + * Builds preview prices for visible plans. + * + * @param value - raw promo code value + * @param userId - user id + * @param workspaceId - workspace id + */ + public async preview(value: string, userId: string, workspaceId: string): Promise { + const promoCode = await this.getValidPromoCode(value, userId, workspaceId); + const benefit = promoCode.benefit; + + if (benefit.type === 'grant_plan') { + const plan = await this.factories.plansFactory.findById(benefit.planId.toString()); + + if (!plan || !isPlanAvailable(plan)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); + } + + return { + value: promoCode.value, + benefitType: benefit.type, + plans: [], + }; + } + + const plans = await this.factories.plansFactory.findAll(); + + return { + value: promoCode.value, + benefitType: benefit.type, + percent: benefit.type === 'percent_discount' ? benefit.percent : undefined, + amount: benefit.type === 'amount_discount' || benefit.type === 'fixed_price' ? benefit.amount : undefined, + plans: plans.map((plan): PromoCodePlanPrice => calculatePromoCodePlanPrice(benefit, plan)), + }; + } + + /** + * 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); + + if (promoCode.benefit.type === 'grant_plan') { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); + } + + const price = calculatePromoCodePlanPrice(promoCode.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, + }; + } + + /** + * Applies grant_plan promo code to workspace. + * + * @param value - raw promo code value + * @param userId - user id + * @param workspace - workspace model + * @param utm - optional UTM data + */ + public async applyGrantPlan(value: string, userId: string, workspace: WorkspaceModel, utm?: PromoCodeUtm): Promise { + const promoCode = await this.getValidPromoCode(value, userId, workspace._id.toString()); + + if (promoCode.benefit.type !== 'grant_plan') { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not grant_plan'); + } + + const plan = await this.factories.plansFactory.findById(promoCode.benefit.planId.toString()); + + if (!plan || !isPlanAvailable(plan)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); + } + + try { + const now = new Date(); + + await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); + await workspace.updateLastChargeDate(now); + await workspace.changePlan(plan._id); + await this.createUsage({ + promoCode, + userId, + workspaceId: workspace._id, + planId: plan._id, + benefitType: promoCode.benefit.type, + utm, + }); + + return plan; + } catch (error) { + if (error instanceof PromoCodeError) { + throw error; + } + + throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed'); + } + } + + /** + * Creates usage after successful payment. + * + * @param params - usage creation params + */ + public async createUsage(params: { + promoCode: PromoCodeModel; + userId: string; + workspaceId: ObjectId; + planId?: ObjectId; + benefitType: PromoCodeBenefitType; + originalAmount?: number; + finalAmount?: number; + discountAmount?: number; + utm?: PromoCodeUtm; + }): Promise { + await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId); + + try { + await this.factories.promoCodeUsagesFactory.create({ + promoCodeId: params.promoCode._id, + userId: params.userId, + workspaceId: params.workspaceId, + planId: params.planId, + benefitType: params.benefitType, + originalAmount: params.originalAmount, + finalAmount: params.finalAmount, + discountAmount: params.discountAmount, + appliedAt: new Date(), + utm: params.utm, + }); + } catch (error) { + if ((error as { code?: number }).code === 11000) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo usage already exists'); + } + + throw error; + } + } + + /** + * 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/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index e1ffc6a9..78dfa45a 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -82,6 +82,8 @@ function createComposePaymentTestSetup(options: { projectsFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; diff --git a/test/utils/promoCodeService.test.ts b/test/utils/promoCodeService.test.ts new file mode 100644 index 00000000..9fb418e5 --- /dev/null +++ b/test/utils/promoCodeService.test.ts @@ -0,0 +1,251 @@ +import { ObjectId } from 'mongodb'; +import PromoCodeService, { + calculatePromoCodePlanPrice, + normalizePromoCodeValue, + PromoCodeError, + PromoCodeErrorCode +} from '../../src/utils/promoCodeService'; + +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), + }, + 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({}), + }, + 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', () => { + it('normalizes promo code value before lookup', () => { + expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026'); + }); + + it('calculates percent discount with min final price', () => { + 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('calculates amount discount with min final price', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'amount_discount', + amount: 1200, + minFinalPrice: 150, + } as any, plan); + + expect(price.finalAmount).toBe(150); + expect(price.discountAmount).toBe(850); + }); + + it('calculates 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('does not apply discount to plan 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('returns preview for percent discount promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 25, + }); + const service = createService(promoCode, { plan }); + + const preview = await service.preview(' promo ', new ObjectId().toString(), new ObjectId().toString()); + + expect(preview).toMatchObject({ + value: 'PROMO', + benefitType: 'percent_discount', + percent: 25, + plans: [{ + isApplicable: true, + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + }], + }); + }); + + it('rejects unknown promo code', async () => { + const service = createService(null); + + await expectPromoError(service.preview('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + + it('rejects 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.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + + it('rejects total usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }, { + limit: 1, + }); + const service = createService(promoCode, { totalUses: 1 }); + + await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('rejects user usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = createService(promoCode, { userUsage: {} }); + + await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('rejects workspace usage limit', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = createService(promoCode, { workspaceUsage: {} }); + + await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + }); + + it('rejects invalid benefit structure', async () => { + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 101, + }); + const service = createService(promoCode); + + await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + + it('rejects selected plan when promo is not applicable', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'amount_discount', + amount: 100, + applicablePlanIds: [new ObjectId()], + }); + const service = createService(promoCode); + + await expectPromoError( + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid + ); + }); + + it('maps duplicate usage creation to limit exceeded error', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = new PromoCodeService({ + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue({ code: 11000 }), + }, + } as any); + + await expectPromoError( + service.createUsage({ + promoCode, + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + planId: new ObjectId(), + benefitType: 'fixed_price', + originalAmount: 1000, + finalAmount: 100, + discountAmount: 900, + }), + PromoCodeErrorCode.LimitExceeded + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 995f6267..eaa0281e 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.2": + version "0.6.2" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.6.2.tgz#69bf4efc93e67c609faf70303cc7df8b085fefd0" + integrity sha512-OmYBOqkzYDWgw5hoZ8PCNu7vc3WzswYsv1SQz8SYLjq39lMLQxNHdmqxXl7jCbR7eWF1WosTjy72leE/hfEowQ== dependencies: bson "^7.0.0" From d03e0935c3f7c8f9e154c22b2750ebd2ee39f9d8 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:49:09 +0000 Subject: [PATCH 02/43] Bump version up to 1.5.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ecfb3516..c20d1fad 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": { From fb4e4196b53596133266d7b70c80fb75ef9102ba Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:12:48 +0300 Subject: [PATCH 03/43] utm --- src/billing/types/paymentData.ts | 10 +++------- src/models/user.ts | 3 ++- src/models/usersFactory.ts | 3 ++- src/resolvers/billingNew.ts | 25 ++++++++----------------- src/resolvers/user.ts | 4 ++-- src/typeDefs/billing.ts | 15 ++------------- src/utils/checksumService.ts | 9 ++------- src/utils/promoCodeService.ts | 8 ++++---- src/utils/utm/utm.ts | 19 +++++++++++++++---- 9 files changed, 40 insertions(+), 56 deletions(-) diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index f7382fa5..e60ab016 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 */ @@ -83,13 +85,7 @@ export interface PaymentData { /** * UTM parameters captured when promo was applied */ - promoUtm?: { - source?: string; - medium?: string; - campaign?: string; - content?: string; - term?: string; - }; + promoUtm?: Utm; /** * True if this is card linking operation – charging minimal amount of money to validate card info */ diff --git a/src/models/user.ts b/src/models/user.ts index 26c696db..2ed99e1b 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 8e1869f8..1939002c 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 3b0d70a0..7e07f453 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -14,6 +14,8 @@ import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult } from '../utils/promoCodeService'; import { publish } from '../rabbitmq'; +import type { Utm } from '@hawk.so/types'; +import { validateUtmParams } from '../utils/utm/utm'; /** * The amount we will debit to confirm the subscription. @@ -30,13 +32,7 @@ interface ComposePaymentArgs { tariffPlanId: string; shouldSaveCard?: boolean; promoCode?: string; - promoUtm?: { - source?: string; - medium?: string; - campaign?: string; - content?: string; - term?: string; - }; + promoUtm?: Utm; }; } @@ -47,13 +43,7 @@ interface PreviewPromoCodeArgs { input: { workspaceId: string; value: string; - utm?: { - source?: string; - medium?: string; - campaign?: string; - content?: string; - term?: string; - }; + utm?: Utm; }; } @@ -132,7 +122,8 @@ export default { finalAmount?: number; discountAmount?: number; }> { - const { workspaceId, tariffPlanId, shouldSaveCard, promoCode, promoUtm } = input; + const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; + const promoUtm = validateUtmParams(input.promoUtm); if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -186,7 +177,7 @@ export default { originalAmount: pricing.originalAmount, finalAmount: pricing.finalAmount, discountAmount: pricing.discountAmount, - promoUtm, + ...(promoUtm && Object.keys(promoUtm).length > 0 ? { promoUtm } : {}), }; } catch (error) { throwPromoCodeGraphQLError(error); @@ -361,7 +352,7 @@ debug: ${Boolean(workspace.isDebug)}` }; } - await promoCodeService.applyGrantPlan(input.value, user.id, workspace, input.utm); + await promoCodeService.applyGrantPlan(input.value, user.id, workspace, validateUtmParams(input.utm)); await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ type: 'unblock-workspace', diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 7cadb46a..653aefd2 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 } 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/typeDefs/billing.ts b/src/typeDefs/billing.ts index df8d8b94..9c791bd8 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -244,7 +244,7 @@ input ComposePaymentInput { """ UTM parameters captured when promo code was applied """ - promoUtm: PromoCodeUtmInput + promoUtm: UtmInput } """ @@ -264,18 +264,7 @@ input PreviewPromoCodeInput { """ UTM parameters captured when promo code was applied """ - utm: PromoCodeUtmInput -} - -""" -UTM data stored with promo usage -""" -input PromoCodeUtmInput { - source: String - medium: String - campaign: String - content: String - term: String + utm: UtmInput } """ diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index a9304861..2d8b20a7 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,4 +1,5 @@ import jwt, { Secret } from 'jsonwebtoken'; +import type { Utm } from '@hawk.so/types'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -50,13 +51,7 @@ interface PlanPurchaseChecksumData { /** * UTM parameters captured when promo was applied */ - promoUtm?: { - source?: string; - medium?: string; - campaign?: string; - content?: string; - term?: string; - }; + promoUtm?: Utm; } interface CardLinkChecksumData { diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index eff93e6b..1af31cbb 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -1,13 +1,13 @@ import { ObjectId } from 'mongodb'; import { PromoCodeBenefit, - PromoCodeBenefitType, - PromoCodeUsageDBScheme + PromoCodeBenefitType } from '@hawk.so/types'; import PlanModel from '../models/plan'; import PromoCodeModel from '../models/promoCode'; import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; +import type { Utm } from '@hawk.so/types'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; const DEFAULT_MIN_FINAL_PRICE = 1; @@ -135,7 +135,7 @@ export interface PromoCodePreviewResult { /** * UTM data stored with promo code usage. */ -export type PromoCodeUtm = PromoCodeUsageDBScheme['utm']; +export type PromoCodeUtm = Utm; /** * Normalizes promo code value before DB lookup. @@ -475,7 +475,7 @@ export default class PromoCodeService { finalAmount: params.finalAmount, discountAmount: params.discountAmount, appliedAt: new Date(), - utm: params.utm, + ...(params.utm && Object.keys(params.utm).length > 0 ? { utm: params.utm } : {}), }); } catch (error) { if ((error as { code?: number }).code === 11000) { diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index c3845e01..d87427cf 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -1,7 +1,18 @@ +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 + */ +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 +30,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: any): 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; } From e85c91e6779976c5365fdb70ac012927cce44266 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:26:45 +0300 Subject: [PATCH 04/43] chore: update @hawk.so/types to version 0.6.3 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c20d1fad..7e68ab31 100644 --- a/package.json +++ b/package.json @@ -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.6.2", + "@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/yarn.lock b/yarn.lock index eaa0281e..0ba7c2ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -510,10 +510,10 @@ dependencies: bson "^7.0.0" -"@hawk.so/types@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.6.2.tgz#69bf4efc93e67c609faf70303cc7df8b085fefd0" - integrity sha512-OmYBOqkzYDWgw5hoZ8PCNu7vc3WzswYsv1SQz8SYLjq39lMLQxNHdmqxXl7jCbR7eWF1WosTjy72leE/hfEowQ== +"@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" From 0aa673888ef2266cf5348d80263500ff0abe6df8 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:43:34 +0300 Subject: [PATCH 05/43] fix --- src/models/promoCodeUsagesFactory.ts | 32 ++++++++++++++++++++-------- src/models/promoCodesFactory.ts | 4 +++- src/resolvers/user.ts | 2 +- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index 6009d099..f9b782ab 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -47,7 +47,10 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { await this.ensureIndexesOnce(); - const usage = await this.collection.findOne({ promoCodeId, userId }); + const usage = await this.collection.findOne({ + promoCodeId, + userId, + }); if (!usage) { return null; @@ -65,7 +68,10 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { await this.ensureIndexesOnce(); - const usage = await this.collection.findOne({ promoCodeId, workspaceId }); + const usage = await this.collection.findOne({ + promoCodeId, + workspaceId, + }); if (!usage) { return null; @@ -96,13 +102,21 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - this.indexesPromise ??= Promise.all([ - this.collection.createIndex({ promoCodeId: 1 }), - this.collection.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true }), - this.collection.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true }), - this.collection.createIndex({ workspaceId: 1 }), - this.collection.createIndex({ userId: 1 }), - ]).then(() => undefined); + if (!this.indexesPromise) { + this.indexesPromise = Promise.all([ + this.collection.createIndex({ promoCodeId: 1 }), + this.collection.createIndex({ + promoCodeId: 1, + userId: 1, + }, { unique: true }), + this.collection.createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { unique: true }), + this.collection.createIndex({ workspaceId: 1 }), + this.collection.createIndex({ userId: 1 }), + ]).then(() => undefined); + } await this.indexesPromise; } diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts index 652fb314..eb5080d4 100644 --- a/src/models/promoCodesFactory.ts +++ b/src/models/promoCodesFactory.ts @@ -48,7 +48,9 @@ export default class PromoCodesFactory extends AbstractModelFactory { - this.indexesPromise ??= this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined); + if (!this.indexesPromise) { + this.indexesPromise = this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined); + } await this.indexesPromise; } diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 653aefd2..7abb7f3b 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -10,7 +10,7 @@ import isE2E from '../utils/isE2E'; import { dateFromObjectId } from '../utils/dates'; import * as telegram from '../utils/telegram'; import { MongoError } from 'mongodb'; -import type { Utm } from '@hawk.so/types'; +import type { Utm, UserDBScheme } from '@hawk.so/types'; import { validateUtmParams } from '../utils/utm/utm'; /** From 72a6e60d760acb7a4d95603590332f2b110c3144 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:05:25 +0300 Subject: [PATCH 06/43] feat(promoCode): enhance discount logic and add tests for plan applicability --- src/utils/promoCodeService.ts | 45 +++++++++++++++++++++++++++-- test/utils/promoCodeService.test.ts | 29 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index 1af31cbb..b1f7e871 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -182,6 +182,15 @@ function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { return benefit.applicablePlanIds.some((planId): boolean => planId.toString() === plan._id.toString()); } +/** + * Returns whether discount promo can affect plan price. + * + * @param plan - tariff plan + */ +function isDiscountablePlan(plan: PlanModel): boolean { + return plan.monthlyCharge > 0 && isPlanAvailable(plan); +} + /** * Calculates discounted price for one plan. * @@ -190,7 +199,9 @@ function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { */ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: PlanModel): PromoCodePlanPrice { const originalAmount = plan.monthlyCharge; - const isApplicable = benefit.type !== 'grant_plan' && isPlanAvailable(plan) && isPlanApplicable(benefit, plan); + const isApplicable = benefit.type !== 'grant_plan' && + isDiscountablePlan(plan) && + isPlanApplicable(benefit, plan); if (!isApplicable) { return { @@ -208,6 +219,16 @@ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: Pla const discountAmount = Math.floor(originalAmount * benefit.percent / 100); const finalAmount = Math.max(originalAmount - discountAmount, minFinalPrice); + if (finalAmount >= originalAmount) { + return { + planId: plan._id.toString(), + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + return { planId: plan._id.toString(), isApplicable: true, @@ -221,6 +242,16 @@ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: Pla const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; const finalAmount = Math.max(originalAmount - benefit.amount, minFinalPrice); + if (finalAmount >= originalAmount) { + return { + planId: plan._id.toString(), + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + return { planId: plan._id.toString(), isApplicable: true, @@ -231,12 +262,22 @@ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: Pla } case 'fixed_price': + if (benefit.amount >= originalAmount) { + return { + planId: plan._id.toString(), + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + return { planId: plan._id.toString(), isApplicable: true, originalAmount, finalAmount: benefit.amount, - discountAmount: Math.max(originalAmount - benefit.amount, 0), + discountAmount: originalAmount - benefit.amount, }; default: diff --git a/test/utils/promoCodeService.test.ts b/test/utils/promoCodeService.test.ts index 9fb418e5..8ae9dd90 100644 --- a/test/utils/promoCodeService.test.ts +++ b/test/utils/promoCodeService.test.ts @@ -122,6 +122,35 @@ describe('PromoCodeService', () => { }); }); + it('does 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('does 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, + }); + }); + it('returns preview for percent discount promo', async () => { const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ From 377fa6ff2c7e8521bf6dd91d6db74105ec837ec4 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:20:02 +0300 Subject: [PATCH 07/43] feat(billing): refactor promo code handling and update payment data structure --- src/billing/cloudpayments.ts | 44 ++++++---- src/billing/types/paymentData.ts | 61 +++++++------ src/resolvers/billingNew.ts | 34 +++----- src/typeDefs/billing.ts | 49 +++++++---- src/utils/checksumService.ts | 38 +-------- src/utils/promoCodeService.ts | 108 ++++++++++++++++++++---- test/integrations/github-routes.test.ts | 42 +++++++++ test/resolvers/project.test.ts | 4 + test/sso/saml/controller.test.ts | 2 + 9 files changed, 250 insertions(+), 132 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 3b82632c..672a3cab 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -162,16 +162,21 @@ export default class CloudPaymentsWebhooks { const recurrentPaymentSettings = data.cloudPayments?.recurrent; - if (data.promoCodeValue && !data.isCardLinkOperation) { + if (data.promo && !data.isCardLinkOperation) { try { const promoCodeService = new PromoCodeService(context.factories); - const promoPricing = await promoCodeService.getPricingForPlan(data.promoCodeValue, data.userId, data.workspaceId, plan); + const promoPricing = await promoCodeService.getPricingForPromoCodeId( + data.promo.id, + data.userId, + data.workspaceId, + plan + ); if ( - promoPricing.promoCode._id.toString() !== data.promoCodeId || - promoPricing.finalAmount !== data.finalAmount || - promoPricing.originalAmount !== data.originalAmount || - promoPricing.discountAmount !== data.discountAmount + promoPricing.benefitType !== data.promo.benefitType || + promoPricing.finalAmount !== data.promo.finalAmount || + promoPricing.originalAmount !== data.promo.originalAmount || + promoPricing.discountAmount !== data.promo.discountAmount ) { this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo code payment data does not match current promo calculation', body); @@ -190,8 +195,8 @@ export default class CloudPaymentsWebhooks { * 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. */ - const expectedAmount = data.finalAmount ?? plan.monthlyCharge; - const isRightAmount = +body.Amount === expectedAmount || (!data.finalAmount && recurrentPaymentSettings?.startDate); + const expectedAmount = data.promo?.finalAmount ?? plan.monthlyCharge; + const isRightAmount = +body.Amount === expectedAmount || (!data.promo?.finalAmount && recurrentPaymentSettings?.startDate); if (!isRightAmount) { this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); @@ -322,20 +327,25 @@ export default class CloudPaymentsWebhooks { await workspace.setSubscriptionId(subscriptionId); } - if (data.promoCodeValue && !data.isCardLinkOperation && data.benefitType) { + if (data.promo && !data.isCardLinkOperation) { const promoCodeService = new PromoCodeService(req.context.factories); - const promoCode = await promoCodeService.getValidPromoCode(data.promoCodeValue, data.userId, data.workspaceId); + const promoPricing = await promoCodeService.getPricingForPromoCodeId( + data.promo.id, + data.userId, + data.workspaceId, + tariffPlan + ); await promoCodeService.createUsage({ - promoCode, + promoCode: promoPricing.promoCode, userId: data.userId, workspaceId: workspace._id, planId: tariffPlan._id, - benefitType: data.benefitType, - originalAmount: data.originalAmount, - finalAmount: data.finalAmount, - discountAmount: data.discountAmount, - utm: data.promoUtm, + benefitType: data.promo.benefitType, + originalAmount: data.promo.originalAmount, + finalAmount: data.promo.finalAmount, + discountAmount: data.promo.discountAmount, + utm: data.promo.utm, }); } } catch (e) { @@ -485,7 +495,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, data.finalAmount ?? tariffPlan.monthlyCharge); + await this.sendReceipt(workspace, tariffPlan, userEmail, data.promo?.finalAmount ?? tariffPlan.monthlyCharge); let messageText = ''; diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index e60ab016..37fa8f71 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -37,55 +37,66 @@ interface CloudPaymentsSettings { recurrent: RecurrentPaymentSettings; } -export interface PaymentData { +/** + * Promo data attached to payment request + */ +export interface PaymentPromoData { /** - * Data for Cloudpayments needs + * Applied promo code id */ - cloudPayments?: CloudPaymentsSettings; + id: string; + /** - * Workspace Identifier + * Promo benefit type */ - workspaceId: string; + benefitType: 'percent_discount' | 'amount_discount' | 'fixed_price'; + /** - * Id of the user making the payment + * Plan price before promo */ - userId: string; + originalAmount: number; + /** - * Workspace current plan id or plan id to change + * Final price after promo */ - tariffPlanId: string; + finalAmount: number; + /** - * If true, we will save user card + * Actual discount amount */ - shouldSaveCard: boolean; + discountAmount: number; + /** - * Applied promo code id + * UTM parameters captured when promo was applied */ - promoCodeId?: string; + utm?: Utm; +} + +export interface PaymentData { /** - * Applied promo code value + * Data for Cloudpayments needs */ - promoCodeValue?: string; + cloudPayments?: CloudPaymentsSettings; /** - * Promo benefit type + * Workspace Identifier */ - benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price'; + workspaceId: string; /** - * Plan price before promo + * Id of the user making the payment */ - originalAmount?: number; + userId: string; /** - * Final price after promo + * Workspace current plan id or plan id to change */ - finalAmount?: number; + tariffPlanId: string; /** - * Actual discount amount + * If true, we will save user card */ - discountAmount?: number; + shouldSaveCard: boolean; /** - * UTM parameters captured when promo was applied + * Applied promo code data */ - promoUtm?: Utm; + promo?: PaymentPromoData; /** * True if this is card linking operation – charging minimal amount of money to validate card info */ diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 7e07f453..fe03dfd7 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -12,7 +12,7 @@ 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, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult } from '../utils/promoCodeService'; +import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../utils/promoCodeService'; import { publish } from '../rabbitmq'; import type { Utm } from '@hawk.so/types'; import { validateUtmParams } from '../utils/utm/utm'; @@ -117,10 +117,13 @@ export default { checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; - promoCode?: string; - originalAmount?: number; - finalAmount?: number; - discountAmount?: number; + promo?: { + id: string; + benefitType: 'percent_discount' | 'amount_discount' | 'fixed_price'; + originalAmount: number; + finalAmount: number; + discountAmount: number; + }; }> { const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; const promoUtm = validateUtmParams(input.promoUtm); @@ -162,7 +165,7 @@ export default { } let paymentAmount = plan.monthlyCharge; - let promoPaymentData; + let paymentPromo; if (promoCode && !isCardLinkOperation) { try { @@ -170,15 +173,7 @@ export default { const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); paymentAmount = pricing.finalAmount; - promoPaymentData = { - promoCodeId: pricing.promoCode._id.toString(), - promoCodeValue: pricing.promoCode.value, - benefitType: pricing.benefitType, - originalAmount: pricing.originalAmount, - finalAmount: pricing.finalAmount, - discountAmount: pricing.discountAmount, - ...(promoUtm && Object.keys(promoUtm).length > 0 ? { promoUtm } : {}), - }; + paymentPromo = buildPaymentPromoData(pricing, promoUtm); } catch (error) { throwPromoCodeGraphQLError(error); } @@ -207,7 +202,7 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), - ...promoPaymentData, + ...(paymentPromo ? { promo: paymentPromo } : {}), }; const checksum = await checksumService.generateChecksum(checksumData); @@ -239,10 +234,7 @@ debug: ${Boolean(workspace.isDebug)}` checksum, nextPaymentDate, cloudPaymentsPublicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', - promoCode: promoPaymentData?.promoCodeValue, - originalAmount: promoPaymentData?.originalAmount, - finalAmount: promoPaymentData?.finalAmount, - discountAmount: promoPaymentData?.discountAmount, + promo: paymentPromo, }; }, }, @@ -394,7 +386,7 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Wrong checksum data'); } - const planPaymentAmount = paymentData.finalAmount ?? plan.monthlyCharge; + const planPaymentAmount = paymentData.promo?.finalAmount ?? plan.monthlyCharge; const token = fullUserInfo.bankCards?.find(card => card.id === args.input.cardId)?.token; diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 9c791bd8..b6668471 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -342,6 +342,36 @@ type PreviewPromoCodeResponse { plans: [PromoCodePlanPrice!]! } +""" +Promo data returned with composePayment +""" +type ComposePaymentPromo { + """ + Applied promo code id + """ + id: ID! + + """ + Promo benefit type + """ + benefitType: PromoCodeBenefitType! + + """ + Plan price before promo + """ + originalAmount: Int! + + """ + Plan price after promo + """ + finalAmount: Int! + + """ + Actual discount amount in money + """ + discountAmount: Int! +} + """ Response of composePayment query """ @@ -382,24 +412,9 @@ type ComposePaymentResponse { cloudPaymentsPublicId: String! """ - Applied promo code value - """ - promoCode: String - - """ - Plan price before promo - """ - originalAmount: Int - - """ - Plan price after promo - """ - finalAmount: Int - - """ - Actual discount amount in money + Applied promo code data """ - discountAmount: Int + promo: ComposePaymentPromo } diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 2d8b20a7..38484fbf 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,5 +1,5 @@ import jwt, { Secret } from 'jsonwebtoken'; -import type { Utm } from '@hawk.so/types'; +import type { PaymentPromoData } from '../billing/types/paymentData'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -25,33 +25,9 @@ interface PlanPurchaseChecksumData { */ nextPaymentDate: string; /** - * Applied promo code id + * Applied promo code data */ - promoCodeId?: string; - /** - * Applied promo code value - */ - promoCodeValue?: string; - /** - * Promo benefit type - */ - benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price'; - /** - * Plan price before promo - */ - originalAmount?: number; - /** - * Final price after promo - */ - finalAmount?: number; - /** - * Actual discount amount - */ - discountAmount?: number; - /** - * UTM parameters captured when promo was applied - */ - promoUtm?: Utm; + promo?: PaymentPromoData; } interface CardLinkChecksumData { @@ -112,13 +88,7 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, - promoCodeId: payload.promoCodeId, - promoCodeValue: payload.promoCodeValue, - benefitType: payload.benefitType, - originalAmount: payload.originalAmount, - finalAmount: payload.finalAmount, - discountAmount: payload.discountAmount, - promoUtm: payload.promoUtm, + promo: payload.promo, }; } } diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index b1f7e871..5c519845 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -8,6 +8,7 @@ import PromoCodeModel from '../models/promoCode'; import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; import type { Utm } from '@hawk.so/types'; +import type { PaymentPromoData } from '../billing/types/paymentData'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; const DEFAULT_MIN_FINAL_PRICE = 1; @@ -327,6 +328,23 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { } } +/** + * Builds promo payload stored in payment checksum. + * + * @param pricing - validated promo pricing + * @param utm - optional UTM data + */ +export function buildPaymentPromoData(pricing: PromoCodePricingResult, utm?: Utm): PaymentPromoData { + return { + id: pricing.promoCode._id.toString(), + benefitType: pricing.benefitType as PaymentPromoData['benefitType'], + originalAmount: pricing.originalAmount, + finalAmount: pricing.finalAmount, + discountAmount: pricing.discountAmount, + ...(utm && Object.keys(utm).length > 0 ? { utm } : {}), + }; +} + /** * Service with promo code validation and usage helpers. */ @@ -365,14 +383,84 @@ export default class PromoCodeService { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); } + await this.validateLoadedPromoCode(promoCode, userId, workspaceId); + + return promoCode; + } + + /** + * 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'); } validateBenefitStructure(promoCode.benefit); await this.validateUsageLimits(promoCode, userId, new ObjectId(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); + } + + /** + * Builds pricing result for validated promo code and plan. + * + * @param promoCode - promo code model + * @param plan - selected plan + */ + private buildPricingResult(promoCode: PromoCodeModel, plan: PlanModel): PromoCodePricingResult { + if (promoCode.benefit.type === 'grant_plan') { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); + } + + const price = calculatePromoCodePlanPrice(promoCode.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, + }; } /** @@ -422,23 +510,7 @@ export default class PromoCodeService { public async getPricingForPlan(value: string, userId: string, workspaceId: string, plan: PlanModel): Promise { const promoCode = await this.getValidPromoCode(value, userId, workspaceId); - if (promoCode.benefit.type === 'grant_plan') { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); - } - - const price = calculatePromoCodePlanPrice(promoCode.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, - }; + return this.buildPricingResult(promoCode, plan); } /** diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 03eacc94..d7483ada 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/project.test.ts b/test/resolvers/project.test.ts index 00a4a907..a01a05f6 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/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 307c9777..6a47b5de 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, }; /** From e926076db08fc6268eb3a77001506755ae4f2043 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:32:05 +0300 Subject: [PATCH 08/43] lint fix --- src/utils/promoCodeService.ts | 98 +++++++++++++++++++---------------- src/utils/utm/utm.ts | 3 +- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index 5c519845..3378b683 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -303,24 +303,28 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { if (!benefit.planId) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan id is missing'); } + return; 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 'amount_discount': if (typeof benefit.amount !== 'number' || benefit.amount <= 0) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Amount 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: @@ -333,6 +337,7 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { * * @param pricing - validated promo pricing * @param utm - optional UTM data + * @returns promo data for payment checksum */ export function buildPaymentPromoData(pricing: PromoCodePricingResult, utm?: Utm): PaymentPromoData { return { @@ -388,26 +393,6 @@ export default class PromoCodeService { return promoCode; } - /** - * 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'); - } - - validateBenefitStructure(promoCode.benefit); - await this.validateUsageLimits(promoCode, userId, new ObjectId(workspaceId)); - } - /** * Validates promo code by id for one selected plan and returns final price. * @@ -437,32 +422,6 @@ export default class PromoCodeService { return this.buildPricingResult(promoCode, plan); } - /** - * Builds pricing result for validated promo code and plan. - * - * @param promoCode - promo code model - * @param plan - selected plan - */ - private buildPricingResult(promoCode: PromoCodeModel, plan: PlanModel): PromoCodePricingResult { - if (promoCode.benefit.type === 'grant_plan') { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); - } - - const price = calculatePromoCodePlanPrice(promoCode.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, - }; - } - /** * Builds preview prices for visible plans. * @@ -599,6 +558,53 @@ export default class PromoCodeService { } } + /** + * 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'); + } + + 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 { + if (promoCode.benefit.type === 'grant_plan') { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); + } + + const price = calculatePromoCodePlanPrice(promoCode.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. * diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index d87427cf..61be7607 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -9,6 +9,7 @@ const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'] as co * 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); @@ -30,7 +31,7 @@ const MAX_UTM_VALUE_LENGTH = 50; * @param {Object} utm - UTM parameters to validate * @returns {Object} - filtered valid UTM parameters */ -export function validateUtmParams(utm: any): Utm | undefined { +export function validateUtmParams(utm: unknown): Utm | undefined { if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { return undefined; } From 4c25dc0997d7a0dc1741800e23bfbeb4be117e56 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:37:36 +0300 Subject: [PATCH 09/43] feat(billing): introduce PaymentPromoBenefitType and update promo data structure --- src/billing/types/paymentData.ts | 9 +++++++-- src/resolvers/billingNew.ts | 9 ++------- src/utils/promoCodeService.ts | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 37fa8f71..658e380a 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,4 +1,9 @@ -import type { Utm } from '@hawk.so/types'; +import type { PromoCodeBenefitType, Utm } from '@hawk.so/types'; + +/** + * Promo benefit types that can be applied during payment. + */ +export type PaymentPromoBenefitType = Exclude; /** * Data for setting up recurring payments @@ -49,7 +54,7 @@ export interface PaymentPromoData { /** * Promo benefit type */ - benefitType: 'percent_discount' | 'amount_discount' | 'fixed_price'; + benefitType: PaymentPromoBenefitType; /** * Plan price before promo diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index fe03dfd7..a79f4d8d 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -14,6 +14,7 @@ import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../utils/promoCodeService'; import { publish } from '../rabbitmq'; +import type { PaymentPromoData } from '../billing/types/paymentData'; import type { Utm } from '@hawk.so/types'; import { validateUtmParams } from '../utils/utm/utm'; @@ -117,13 +118,7 @@ export default { checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; - promo?: { - id: string; - benefitType: 'percent_discount' | 'amount_discount' | 'fixed_price'; - originalAmount: number; - finalAmount: number; - discountAmount: number; - }; + promo?: PaymentPromoData; }> { const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; const promoUtm = validateUtmParams(input.promoUtm); diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index 3378b683..55177334 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -8,7 +8,7 @@ import PromoCodeModel from '../models/promoCode'; import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; import type { Utm } from '@hawk.so/types'; -import type { PaymentPromoData } from '../billing/types/paymentData'; +import type { PaymentPromoData, PaymentPromoBenefitType } from '../billing/types/paymentData'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; const DEFAULT_MIN_FINAL_PRICE = 1; @@ -342,7 +342,7 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { export function buildPaymentPromoData(pricing: PromoCodePricingResult, utm?: Utm): PaymentPromoData { return { id: pricing.promoCode._id.toString(), - benefitType: pricing.benefitType as PaymentPromoData['benefitType'], + benefitType: pricing.benefitType as PaymentPromoBenefitType, originalAmount: pricing.originalAmount, finalAmount: pricing.finalAmount, discountAmount: pricing.discountAmount, From 515a9e8e074fa7eed8d9be943dcb43d4ae53e9bf Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:11:03 +0300 Subject: [PATCH 10/43] refactor(billing): streamline promo code handling and update payment data references --- src/billing/cloudpayments.ts | 27 +++++++++------------------ src/billing/types/paymentData.ts | 32 ++++---------------------------- src/resolvers/billingNew.ts | 29 ++++++++++++++++++++++------- src/utils/promoCodeService.ts | 16 ++++++---------- 4 files changed, 41 insertions(+), 63 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 672a3cab..5783cfe7 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -161,27 +161,18 @@ export default class CloudPaymentsWebhooks { } const recurrentPaymentSettings = data.cloudPayments?.recurrent; + let promoPricing; if (data.promo && !data.isCardLinkOperation) { try { const promoCodeService = new PromoCodeService(context.factories); - const promoPricing = await promoCodeService.getPricingForPromoCodeId( + + promoPricing = await promoCodeService.getPricingForPromoCodeId( data.promo.id, data.userId, data.workspaceId, plan ); - - if ( - promoPricing.benefitType !== data.promo.benefitType || - promoPricing.finalAmount !== data.promo.finalAmount || - promoPricing.originalAmount !== data.promo.originalAmount || - promoPricing.discountAmount !== data.promo.discountAmount - ) { - this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo code payment data does not match current promo calculation', body); - - return; - } } catch (e) { const error = e as Error; @@ -195,8 +186,8 @@ export default class CloudPaymentsWebhooks { * 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. */ - const expectedAmount = data.promo?.finalAmount ?? plan.monthlyCharge; - const isRightAmount = +body.Amount === expectedAmount || (!data.promo?.finalAmount && recurrentPaymentSettings?.startDate); + 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); @@ -341,10 +332,10 @@ export default class CloudPaymentsWebhooks { userId: data.userId, workspaceId: workspace._id, planId: tariffPlan._id, - benefitType: data.promo.benefitType, - originalAmount: data.promo.originalAmount, - finalAmount: data.promo.finalAmount, - discountAmount: data.promo.discountAmount, + benefitType: promoPricing.benefitType, + originalAmount: promoPricing.originalAmount, + finalAmount: promoPricing.finalAmount, + discountAmount: promoPricing.discountAmount, utm: data.promo.utm, }); } diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 658e380a..e9743632 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,9 +1,4 @@ -import type { PromoCodeBenefitType, Utm } from '@hawk.so/types'; - -/** - * Promo benefit types that can be applied during payment. - */ -export type PaymentPromoBenefitType = Exclude; +import type { Utm } from '@hawk.so/types'; /** * Data for setting up recurring payments @@ -43,7 +38,8 @@ interface CloudPaymentsSettings { } /** - * Promo data attached to payment request + * Promo reference attached to payment request. + * Amounts are resolved on the server by promo id during check/pay. */ export interface PaymentPromoData { /** @@ -51,26 +47,6 @@ export interface PaymentPromoData { */ id: string; - /** - * Promo benefit type - */ - benefitType: PaymentPromoBenefitType; - - /** - * Plan price before promo - */ - originalAmount: number; - - /** - * Final price after promo - */ - finalAmount: number; - - /** - * Actual discount amount - */ - discountAmount: number; - /** * UTM parameters captured when promo was applied */ @@ -99,7 +75,7 @@ export interface PaymentData { */ shouldSaveCard: boolean; /** - * Applied promo code data + * Applied promo code reference */ promo?: PaymentPromoData; /** diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index a79f4d8d..d048b0e5 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -5,7 +5,9 @@ import UserModel from '../models/user'; import { BusinessOperationPayloadType, PayloadOfDepositByUser, - PayloadOfWorkspacePlanPurchase + PayloadOfWorkspacePlanPurchase, + PromoCodeBenefitType, + Utm } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; import { UserInputError } from 'apollo-server-express'; @@ -15,7 +17,6 @@ import { TelegramBotURLs } from '../utils/telegram'; import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../utils/promoCodeService'; import { publish } from '../rabbitmq'; import type { PaymentPromoData } from '../billing/types/paymentData'; -import type { Utm } from '@hawk.so/types'; import { validateUtmParams } from '../utils/utm/utm'; /** @@ -118,7 +119,13 @@ export default { checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; - promo?: PaymentPromoData; + promo?: { + id: string; + benefitType: PromoCodeBenefitType; + originalAmount: number; + finalAmount: number; + discountAmount: number; + }; }> { const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; const promoUtm = validateUtmParams(input.promoUtm); @@ -160,7 +167,8 @@ export default { } let paymentAmount = plan.monthlyCharge; - let paymentPromo; + let paymentPromoChecksum: PaymentPromoData | undefined; + let composePaymentPromo; if (promoCode && !isCardLinkOperation) { try { @@ -168,7 +176,14 @@ export default { const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); paymentAmount = pricing.finalAmount; - paymentPromo = buildPaymentPromoData(pricing, promoUtm); + paymentPromoChecksum = buildPaymentPromoData(pricing.promoCode._id.toString(), promoUtm); + composePaymentPromo = { + id: pricing.promoCode._id.toString(), + benefitType: pricing.benefitType, + originalAmount: pricing.originalAmount, + finalAmount: pricing.finalAmount, + discountAmount: pricing.discountAmount, + }; } catch (error) { throwPromoCodeGraphQLError(error); } @@ -197,7 +212,7 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), - ...(paymentPromo ? { promo: paymentPromo } : {}), + ...(paymentPromoChecksum ? { promo: paymentPromoChecksum } : {}), }; const checksum = await checksumService.generateChecksum(checksumData); @@ -229,7 +244,7 @@ debug: ${Boolean(workspace.isDebug)}` checksum, nextPaymentDate, cloudPaymentsPublicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', - promo: paymentPromo, + promo: composePaymentPromo, }; }, }, diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index 55177334..917d659e 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -8,7 +8,7 @@ import PromoCodeModel from '../models/promoCode'; import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; import type { Utm } from '@hawk.so/types'; -import type { PaymentPromoData, PaymentPromoBenefitType } from '../billing/types/paymentData'; +import type { PaymentPromoData } from '../billing/types/paymentData'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; const DEFAULT_MIN_FINAL_PRICE = 1; @@ -333,19 +333,15 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { } /** - * Builds promo payload stored in payment checksum. + * Builds promo reference stored in payment checksum. * - * @param pricing - validated promo pricing + * @param promoCodeId - applied promo code id * @param utm - optional UTM data - * @returns promo data for payment checksum + * @returns promo reference for payment checksum */ -export function buildPaymentPromoData(pricing: PromoCodePricingResult, utm?: Utm): PaymentPromoData { +export function buildPaymentPromoData(promoCodeId: string, utm?: Utm): PaymentPromoData { return { - id: pricing.promoCode._id.toString(), - benefitType: pricing.benefitType as PaymentPromoBenefitType, - originalAmount: pricing.originalAmount, - finalAmount: pricing.finalAmount, - discountAmount: pricing.discountAmount, + id: promoCodeId, ...(utm && Object.keys(utm).length > 0 ? { utm } : {}), }; } From 94001e86569815fa4607563fc55a722a183db6ed Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:15:35 +0300 Subject: [PATCH 11/43] refactor(billing): enhance payment amount validation and improve promo code index handling --- src/billing/cloudpayments.ts | 15 ++++++++++++--- src/models/promoCodeUsagesFactory.ts | 5 ++++- src/models/promoCodesFactory.ts | 5 ++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 5783cfe7..23515eab 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -183,8 +183,17 @@ export default class CloudPaymentsWebhooks { } /** - * 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. + * 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); @@ -486,7 +495,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, data.promo?.finalAmount ?? tariffPlan.monthlyCharge); + await this.sendReceipt(workspace, tariffPlan, userEmail, +body.Amount); let messageText = ''; diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index f9b782ab..a6d2184c 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -99,7 +99,10 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { if (!this.indexesPromise) { diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts index eb5080d4..530f670a 100644 --- a/src/models/promoCodesFactory.ts +++ b/src/models/promoCodesFactory.ts @@ -45,7 +45,10 @@ export default class PromoCodesFactory extends AbstractModelFactory { if (!this.indexesPromise) { From 5b2f68a3957817966dd18a3bb6bd43722c1d7886 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:18:55 +0300 Subject: [PATCH 12/43] feat(billing): enhance promo code validation and extend admin checks in billing logic --- src/directives/requireAdmin.ts | 8 ++++++++ src/resolvers/billingNew.ts | 15 ++++++++------- src/typeDefs/billing.ts | 5 +++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/directives/requireAdmin.ts b/src/directives/requireAdmin.ts index 453b161b..3acb3318 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/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d048b0e5..8dcfc135 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -318,7 +318,14 @@ debug: ${Boolean(workspace.isDebug)}` Mutation: { /** - * Preview discount promo or immediately apply grant_plan promo. + * Validates promo code for workspace and returns calculated prices. + * + * Preview here means a dry-run for discount promos: server recalculates prices + * for all visible plans and returns them without creating promo usage. + * For grant_plan promo preview becomes apply: workspace plan is changed immediately, + * usage is stored, and response contains applied: true. + * + * Access check is handled by @requireAdmin on GraphQL schema. * * @param _obj - parent object * @param input - promo code input @@ -336,12 +343,6 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError(PromoCodeErrorCode.Invalid); } - const member = await workspace.getMemberInfo(user.id); - - if (!member || !('isAdmin' in member) || !member.isAdmin) { - throw new UserInputError(PromoCodeErrorCode.Invalid); - } - const promoCodeService = new PromoCodeService(factories); try { diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index b6668471..76b2291e 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -467,9 +467,10 @@ type PayWithCardResponse { extend type Mutation { """ - Previews promo code discounts or applies grant_plan promo immediately + Validates promo code for workspace admin and returns calculated prices, + or immediately applies grant_plan promo """ - previewPromoCode(input: PreviewPromoCodeInput!): PreviewPromoCodeResponse! + previewPromoCode(input: PreviewPromoCodeInput!): PreviewPromoCodeResponse! @requireAdmin """ Remove card From 0b74706c241f898944c705fd2d61732831da2cfb Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:20:38 +0300 Subject: [PATCH 13/43] feat(billing): implement previewOrApplyPromoCode function to streamline promo code application and workspace unblocking --- src/resolvers/billingNew.ts | 78 +++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 8dcfc135..0df302e7 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -62,6 +62,63 @@ function throwPromoCodeGraphQLError(error: unknown): never { throw new UserInputError(PromoCodeErrorCode.ApplyFailed); } +/** + * Sends task to limiter worker to unblock workspace after plan became valid again. + * Same mechanism is used after successful payment or manual plan change. + * + * @param workspaceId - workspace id to unblock + */ +async function notifyLimiterToUnblockWorkspace(workspaceId: string): Promise { + await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId, + })); +} + +/** + * Validates promo code and either returns discount preview or applies grant_plan promo. + * + * Discount promos: returns recalculated plan prices with applied: false, no side effects. + * Grant plan promo: applies plan immediately, stores usage, then unblocks workspace in limiter. + * Unblock is not caused by preview itself — it runs only after grant_plan apply, + * because workspace received a valid plan the same way as after paid plan change. + * + * @param promoCodeService - promo code service instance + * @param input - promo code mutation input + * @param userId - current user id + * @param workspace - workspace model + * @returns promo preview or apply result + */ +async function previewOrApplyPromoCode( + promoCodeService: PromoCodeService, + input: PreviewPromoCodeArgs['input'], + userId: string, + workspace: WorkspaceModel +): Promise { + const promoPreview = await promoCodeService.preview(input.value, userId, input.workspaceId); + + if (promoPreview.benefitType !== 'grant_plan') { + return { + ...promoPreview, + applied: false, + }; + } + + await promoCodeService.applyGrantPlan( + input.value, + userId, + workspace, + validateUtmParams(input.utm) + ); + + await notifyLimiterToUnblockWorkspace(workspace._id.toString()); + + return { + ...promoPreview, + applied: true, + }; +} + /** * Data for processing payment with saved card */ @@ -346,26 +403,7 @@ debug: ${Boolean(workspace.isDebug)}` const promoCodeService = new PromoCodeService(factories); try { - const preview = await promoCodeService.preview(input.value, user.id, input.workspaceId); - - if (preview.benefitType !== 'grant_plan') { - return { - ...preview, - applied: false, - }; - } - - await promoCodeService.applyGrantPlan(input.value, user.id, workspace, validateUtmParams(input.utm)); - - await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ - type: 'unblock-workspace', - workspaceId: workspace._id.toString(), - })); - - return { - ...preview, - applied: true, - }; + return await previewOrApplyPromoCode(promoCodeService, input, user.id, workspace); } catch (error) { throwPromoCodeGraphQLError(error); } From 1197be95a50176ed973d2232d8003c43d2327a98 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:22:47 +0300 Subject: [PATCH 14/43] refactor(billing): update promo code handling to calculate payment amount based on promo validity --- src/resolvers/billingNew.ts | 17 ++++++++++++++++- src/utils/promoCodeService.ts | 10 +++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 0df302e7..f11829db 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -435,7 +435,22 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Wrong checksum data'); } - const planPaymentAmount = paymentData.promo?.finalAmount ?? plan.monthlyCharge; + let planPaymentAmount = plan.monthlyCharge; + + if (paymentData.promo?.id) { + try { + const pricing = await new PromoCodeService(factories).getPricingForPromoCodeId( + paymentData.promo.id, + user.id, + paymentData.workspaceId, + plan + ); + + planPaymentAmount = pricing.finalAmount; + } catch (error) { + throwPromoCodeGraphQLError(error); + } + } const token = fullUserInfo.bankCards?.find(card => card.id === args.input.cardId)?.token; diff --git a/src/utils/promoCodeService.ts b/src/utils/promoCodeService.ts index 917d659e..05b7c2de 100644 --- a/src/utils/promoCodeService.ts +++ b/src/utils/promoCodeService.ts @@ -157,11 +157,11 @@ function isAllowedPromoValue(value: string): boolean { } /** - * Returns whether plan is available for purchase/apply. + * Returns whether plan is available for purchase (not hidden). * * @param plan - tariff plan */ -function isPlanAvailable(plan: PlanModel): boolean { +function isPlanAvailableForPurchase(plan: PlanModel): boolean { return plan.isHidden !== true; } @@ -189,7 +189,7 @@ function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { * @param plan - tariff plan */ function isDiscountablePlan(plan: PlanModel): boolean { - return plan.monthlyCharge > 0 && isPlanAvailable(plan); + return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); } /** @@ -432,7 +432,7 @@ export default class PromoCodeService { if (benefit.type === 'grant_plan') { const plan = await this.factories.plansFactory.findById(benefit.planId.toString()); - if (!plan || !isPlanAvailable(plan)) { + if (!plan || !isPlanAvailableForPurchase(plan)) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); } @@ -485,7 +485,7 @@ export default class PromoCodeService { const plan = await this.factories.plansFactory.findById(promoCode.benefit.planId.toString()); - if (!plan || !isPlanAvailable(plan)) { + if (!plan || !isPlanAvailableForPurchase(plan)) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); } From 210de9fb1324bf0852c5b1261e26883fcfd17f8b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:24:59 +0300 Subject: [PATCH 15/43] refactor(billing): move PromoCodeService to services directory and remove unused utils --- src/billing/cloudpayments.ts | 2 +- src/resolvers/billingNew.ts | 2 +- src/{utils => services}/promoCodeService.ts | 6 + test/services/promoCodeService.test.ts | 290 ++++++++++++++++++++ test/utils/promoCodeService.test.ts | 280 ------------------- 5 files changed, 298 insertions(+), 282 deletions(-) rename src/{utils => services}/promoCodeService.ts (97%) create mode 100644 test/services/promoCodeService.test.ts delete mode 100644 test/utils/promoCodeService.test.ts diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 23515eab..45665fa8 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -42,7 +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 '../utils/promoCodeService'; +import PromoCodeService from '../services/promoCodeService'; const PENNY_MULTIPLIER = 100; diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index f11829db..0a347730 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -14,7 +14,7 @@ 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, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../utils/promoCodeService'; +import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../services/promoCodeService'; import { publish } from '../rabbitmq'; import type { PaymentPromoData } from '../billing/types/paymentData'; import { validateUtmParams } from '../utils/utm/utm'; diff --git a/src/utils/promoCodeService.ts b/src/services/promoCodeService.ts similarity index 97% rename from src/utils/promoCodeService.ts rename to src/services/promoCodeService.ts index 05b7c2de..e6430e4d 100644 --- a/src/utils/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -421,6 +421,12 @@ export default class PromoCodeService { /** * Builds preview prices for visible plans. * + * Must run on the API: promo terms and limits live in the database and are re-checked + * on composePayment/check/pay. The frontend only sends promo id in the payment checksum; + * preview is the user-facing step that validates the code and returns prices to display. + * Pure price math is in {@link calculatePromoCodePlanPrice}; this method adds DB lookup, + * expiry/usage limits, and grant_plan availability checks. + * * @param value - raw promo code value * @param userId - user id * @param workspaceId - workspace id diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts new file mode 100644 index 00000000..865c0f97 --- /dev/null +++ b/test/services/promoCodeService.test.ts @@ -0,0 +1,290 @@ +import { ObjectId } from 'mongodb'; +import PromoCodeService, { + calculatePromoCodePlanPrice, + normalizePromoCodeValue, + PromoCodeError, + PromoCodeErrorCode +} from '../../src/services/promoCodeService'; + +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), + }, + 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({}), + }, + 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 amount discount with min final price cap', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'amount_discount', + amount: 1200, + minFinalPrice: 150, + } as any, plan); + + expect(price.finalAmount).toBe(150); + expect(price.discountAmount).toBe(850); + }); + + 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('preview()', () => { + it('should return preview for percent discount promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 25, + }); + const service = createService(promoCode, { plan }); + + const preview = await service.preview(' promo ', new ObjectId().toString(), new ObjectId().toString()); + + expect(preview).toMatchObject({ + value: 'PROMO', + benefitType: 'percent_discount', + percent: 25, + plans: [{ + isApplicable: true, + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + }], + }); + }); + + it('should reject unknown promo code', async () => { + const service = createService(null); + + await expectPromoError(service.preview('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.preview('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.preview('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.preview('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.preview('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.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); + }); + + describe('getPricingForPlan()', () => { + it('should reject selected plan when promo is not applicable', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const promoCode = createPromoCode({ + type: 'amount_discount', + amount: 100, + 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 map duplicate usage creation to limit exceeded error', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const service = new PromoCodeService({ + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue({ code: 11000 }), + }, + } as any); + + await expectPromoError( + service.createUsage({ + promoCode, + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + planId: new ObjectId(), + benefitType: 'fixed_price', + originalAmount: 1000, + finalAmount: 100, + discountAmount: 900, + }), + PromoCodeErrorCode.LimitExceeded + ); + }); + }); +}); diff --git a/test/utils/promoCodeService.test.ts b/test/utils/promoCodeService.test.ts deleted file mode 100644 index 8ae9dd90..00000000 --- a/test/utils/promoCodeService.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { ObjectId } from 'mongodb'; -import PromoCodeService, { - calculatePromoCodePlanPrice, - normalizePromoCodeValue, - PromoCodeError, - PromoCodeErrorCode -} from '../../src/utils/promoCodeService'; - -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), - }, - 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({}), - }, - 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', () => { - it('normalizes promo code value before lookup', () => { - expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026'); - }); - - it('calculates percent discount with min final price', () => { - 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('calculates amount discount with min final price', () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const price = calculatePromoCodePlanPrice({ - type: 'amount_discount', - amount: 1200, - minFinalPrice: 150, - } as any, plan); - - expect(price.finalAmount).toBe(150); - expect(price.discountAmount).toBe(850); - }); - - it('calculates 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('does not apply discount to plan 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('does 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('does 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, - }); - }); - - it('returns preview for percent discount promo', async () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const promoCode = createPromoCode({ - type: 'percent_discount', - percent: 25, - }); - const service = createService(promoCode, { plan }); - - const preview = await service.preview(' promo ', new ObjectId().toString(), new ObjectId().toString()); - - expect(preview).toMatchObject({ - value: 'PROMO', - benefitType: 'percent_discount', - percent: 25, - plans: [{ - isApplicable: true, - originalAmount: 1000, - finalAmount: 750, - discountAmount: 250, - }], - }); - }); - - it('rejects unknown promo code', async () => { - const service = createService(null); - - await expectPromoError(service.preview('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); - }); - - it('rejects 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.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); - }); - - it('rejects total usage limit', async () => { - const promoCode = createPromoCode({ - type: 'fixed_price', - amount: 100, - }, { - limit: 1, - }); - const service = createService(promoCode, { totalUses: 1 }); - - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); - }); - - it('rejects user usage limit', async () => { - const promoCode = createPromoCode({ - type: 'fixed_price', - amount: 100, - }); - const service = createService(promoCode, { userUsage: {} }); - - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); - }); - - it('rejects workspace usage limit', async () => { - const promoCode = createPromoCode({ - type: 'fixed_price', - amount: 100, - }); - const service = createService(promoCode, { workspaceUsage: {} }); - - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); - }); - - it('rejects invalid benefit structure', async () => { - const promoCode = createPromoCode({ - type: 'percent_discount', - percent: 101, - }); - const service = createService(promoCode); - - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); - }); - - it('rejects selected plan when promo is not applicable', async () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const promoCode = createPromoCode({ - type: 'amount_discount', - amount: 100, - applicablePlanIds: [new ObjectId()], - }); - const service = createService(promoCode); - - await expectPromoError( - service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), - PromoCodeErrorCode.Invalid - ); - }); - - it('maps duplicate usage creation to limit exceeded error', async () => { - const promoCode = createPromoCode({ - type: 'fixed_price', - amount: 100, - }); - const service = new PromoCodeService({ - promoCodeUsagesFactory: { - countByPromoCodeId: jest.fn().mockResolvedValue(0), - findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), - findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - create: jest.fn().mockRejectedValue({ code: 11000 }), - }, - } as any); - - await expectPromoError( - service.createUsage({ - promoCode, - userId: new ObjectId().toString(), - workspaceId: new ObjectId(), - planId: new ObjectId(), - benefitType: 'fixed_price', - originalAmount: 1000, - finalAmount: 100, - discountAmount: 900, - }), - PromoCodeErrorCode.LimitExceeded - ); - }); -}); From cad077702ae3f64f871573849cf6dec04ea27224 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:27:47 +0300 Subject: [PATCH 16/43] fix(billing): improve error handling in workspace billing updates and promo code processing --- src/billing/cloudpayments.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 45665fa8..b4d45bfc 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -326,8 +326,16 @@ export default class CloudPaymentsWebhooks { if (subscriptionId) { await workspace.setSubscriptionId(subscriptionId); } + } catch (e) { + const error = e as Error; - if (data.promo && !data.isCardLinkOperation) { + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + + return; + } + + if (data.promo && !data.isCardLinkOperation) { + try { const promoCodeService = new PromoCodeService(req.context.factories); const promoPricing = await promoCodeService.getPricingForPromoCodeId( data.promo.id, @@ -347,13 +355,11 @@ export default class CloudPaymentsWebhooks { discountAmount: promoPricing.discountAmount, utm: data.promo.utm, }); - } - } catch (e) { - const error = e as Error; - - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + } catch (e) { + const error = e as Error; - return; + console.error(`[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body); + } } // let accountId = workspace.accountId; From 2f8e5b578e59fa26361eb0d82567459637dca688 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:29:00 +0300 Subject: [PATCH 17/43] feat(billing): add tests for promo code application and validation in billingNew resolver --- test/resolvers/billingNew.test.ts | 363 ++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index 78dfa45a..868c4a34 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -1,8 +1,22 @@ 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' }, +})); + 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 { publish } from '../../src/rabbitmq'; +import { PromoCodeErrorCode } from '../../src/services/promoCodeService'; // Set environment variables for test process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; @@ -99,6 +113,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 previewPromoCode tests. + */ +function createPreviewPromoCodeTestSetup(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 () => { @@ -201,5 +309,260 @@ 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(750); + expect(result.promo).toMatchObject({ + id: promoCodeId.toString(), + benefitType: 'percent_discount', + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + }); + + 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.promo).toBeUndefined(); + }); + }); + + describe('previewPromoCode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return discount preview without applying grant plan side effects', async () => { + const promoCodeId = new ObjectId(); + const { mockContext, workspaceId, workspaceMock } = createPreviewPromoCodeTestSetup({ + promoCode: { + _id: promoCodeId, + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + }, + }); + + const result = await billingNewResolver.Mutation.previewPromoCode( + undefined, + { + input: { + workspaceId, + value: 'save25', + }, + }, + mockContext + ); + + expect(result).toMatchObject({ + applied: false, + benefitType: 'percent_discount', + percent: 25, + plans: [{ + isApplicable: true, + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + }], + }); + expect(workspaceMock.changePlan).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('should apply grant_plan promo and publish workspace unblock', async () => { + const promoCodeId = new ObjectId(); + const grantPlanId = new ObjectId(); + const { mockContext, workspaceId, workspaceMock } = createPreviewPromoCodeTestSetup({ + 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, + }, + }); + + const result = await billingNewResolver.Mutation.previewPromoCode( + undefined, + { + input: { + workspaceId, + value: 'grant', + }, + }, + mockContext + ); + + expect(result).toMatchObject({ + applied: true, + benefitType: 'grant_plan', + plans: [], + }); + expect(workspaceMock.changePlan).toHaveBeenCalledWith(grantPlanId); + expect(publish).toHaveBeenCalledWith( + 'cron-tasks', + 'cron-tasks/limiter', + JSON.stringify({ + type: 'unblock-workspace', + workspaceId, + }) + ); + }); + + 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.previewPromoCode( + 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 } = createPreviewPromoCodeTestSetup({ + promoCode: null, + }); + + await expect( + billingNewResolver.Mutation.previewPromoCode( + undefined, + { + input: { + workspaceId, + value: 'missing', + }, + }, + mockContext + ) + ).rejects.toMatchObject({ + message: PromoCodeErrorCode.Invalid, + }); + }); }); }) From be4f3b2d419a23f186cc40780596793174df19ba Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:44:30 +0300 Subject: [PATCH 18/43] feat(billing): implement promo usage reservation and rollback mechanism in billing logic --- src/billing/cloudpayments.ts | 88 +++++++++++++++++--------- src/models/promoCodeUsagesFactory.ts | 41 +++++++----- src/services/promoCodeService.ts | 36 +++++++++-- test/billing/cloudpayments.test.ts | 86 +++++++++++++++++++++++++ test/services/promoCodeService.test.ts | 80 ++++++++++++++++++++++- 5 files changed, 278 insertions(+), 53 deletions(-) create mode 100644 test/billing/cloudpayments.test.ts diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index b4d45bfc..10c7121b 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -163,6 +163,13 @@ export default class CloudPaymentsWebhooks { const recurrentPaymentSettings = data.cloudPayments?.recurrent; let promoPricing; + /** + * Record promo usage before applying paid benefits. + * + * /pay runs after CloudPayments has accepted the charge, but workspace plan + * must not be changed if promo usage cannot be stored. Otherwise a transient + * DB/limit error would grant a discounted plan without consuming the promo. + */ if (data.promo && !data.isCardLinkOperation) { try { const promoCodeService = new PromoCodeService(context.factories); @@ -301,6 +308,36 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo && !data.isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(req.context.factories); + const promoPricing = await promoCodeService.getPricingForPromoCodeId( + data.promo.id, + data.userId, + data.workspaceId, + tariffPlan + ); + + await promoCodeService.createUsage({ + promoCode: promoPricing.promoCode, + userId: data.userId, + workspaceId: workspace._id, + planId: tariffPlan._id, + benefitType: promoPricing.benefitType, + originalAmount: promoPricing.originalAmount, + finalAmount: promoPricing.finalAmount, + discountAmount: promoPricing.discountAmount, + utm: data.promo.utm, + }); + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body); + + return; + } + } + try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); @@ -334,34 +371,6 @@ export default class CloudPaymentsWebhooks { return; } - if (data.promo && !data.isCardLinkOperation) { - try { - const promoCodeService = new PromoCodeService(req.context.factories); - const promoPricing = await promoCodeService.getPricingForPromoCodeId( - data.promo.id, - data.userId, - data.workspaceId, - tariffPlan - ); - - await promoCodeService.createUsage({ - promoCode: promoPricing.promoCode, - userId: data.userId, - workspaceId: workspace._id, - planId: tariffPlan._id, - benefitType: promoPricing.benefitType, - originalAmount: promoPricing.originalAmount, - finalAmount: promoPricing.finalAmount, - discountAmount: promoPricing.discountAmount, - utm: data.promo.utm, - }); - } catch (e) { - const error = e as Error; - - console.error(`[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body); - } - } - // let accountId = workspace.accountId; /* @@ -824,10 +833,29 @@ status: ${body.Status}` */ 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, }; } diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index a6d2184c..14a77e14 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -98,6 +98,19 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { + await this.collection.deleteOne({ + _id: usageId, + }); + } + /** * Ensures promo usage indexes exist before queries. * @@ -105,21 +118,19 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - if (!this.indexesPromise) { - this.indexesPromise = Promise.all([ - this.collection.createIndex({ promoCodeId: 1 }), - this.collection.createIndex({ - promoCodeId: 1, - userId: 1, - }, { unique: true }), - this.collection.createIndex({ - promoCodeId: 1, - workspaceId: 1, - }, { unique: true }), - this.collection.createIndex({ workspaceId: 1 }), - this.collection.createIndex({ userId: 1 }), - ]).then(() => undefined); - } + this.indexesPromise ??= Promise.all([ + this.collection.createIndex({ promoCodeId: 1 }), + this.collection.createIndex({ + promoCodeId: 1, + userId: 1, + }, { unique: true }), + this.collection.createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { unique: true }), + this.collection.createIndex({ workspaceId: 1 }), + this.collection.createIndex({ userId: 1 }), + ]).then(() => undefined); await this.indexesPromise; } diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index e6430e4d..32310470 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -5,6 +5,7 @@ import { } from '@hawk.so/types'; import PlanModel from '../models/plan'; import PromoCodeModel from '../models/promoCode'; +import PromoCodeUsageModel from '../models/promoCodeUsage'; import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; import type { Utm } from '@hawk.so/types'; @@ -498,10 +499,13 @@ export default class PromoCodeService { try { const now = new Date(); - await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); - await workspace.updateLastChargeDate(now); - await workspace.changePlan(plan._id); - await this.createUsage({ + /** + * Reserve usage before granting the plan. + * + * This makes promo usage a precondition for the benefit: if limits are exhausted + * or the insert fails, workspace state is not changed. + */ + const usage = await this.createUsage({ promoCode, userId, workspaceId: workspace._id, @@ -510,6 +514,20 @@ export default class PromoCodeService { utm, }); + try { + await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); + await workspace.updateLastChargeDate(now); + await workspace.changePlan(plan._id); + } catch (error) { + try { + await this.factories.promoCodeUsagesFactory.deleteById(usage._id); + } catch (rollbackError) { + console.error('Failed to rollback promo usage after grant_plan apply failure', rollbackError); + } + + throw error; + } + return plan; } catch (error) { if (error instanceof PromoCodeError) { @@ -521,9 +539,13 @@ export default class PromoCodeService { } /** - * Creates usage after successful payment. + * Creates usage after successful payment or before immediate grant_plan apply. + * + * Unique indexes on promoCodeId + userId/workspaceId make this method the durable + * reservation point. Callers should grant the promo benefit only after it succeeds. * * @param params - usage creation params + * @returns created promo usage */ public async createUsage(params: { promoCode: PromoCodeModel; @@ -535,11 +557,11 @@ export default class PromoCodeService { finalAmount?: number; discountAmount?: number; utm?: PromoCodeUtm; - }): Promise { + }): Promise { await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId); try { - await this.factories.promoCodeUsagesFactory.create({ + return await this.factories.promoCodeUsagesFactory.create({ promoCodeId: params.promoCode._id, userId: params.userId, workspaceId: params.workspaceId, diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts new file mode 100644 index 00000000..d7d981a0 --- /dev/null +++ b/test/billing/cloudpayments.test.ts @@ -0,0 +1,86 @@ +import '../../src/env-test'; + +jest.mock('cloudpayments', () => ({ + ClientService: jest.fn().mockImplementation(() => ({ + getReceiptApi: jest.fn().mockReturnValue({}), + getClientApi: jest.fn().mockReturnValue({}), + })), + ReceiptTypes: { + Income: 'Income', + }, + TaxationSystem: { + Common: 'Common', + }, +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn().mockReturnValue({}), + }, + }, +})); + +import { ObjectId } from 'mongodb'; +import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; +import checksumService from '../../src/utils/checksumService'; + +process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; + +describe('CloudPaymentsWebhooks', () => { + 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, + }); + }); + }); +}); diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 865c0f97..3a8438be 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -48,7 +48,8 @@ function createService(promoCode: any, options: { 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({}), + create: jest.fn().mockResolvedValue({ _id: new ObjectId() }), + deleteById: jest.fn().mockResolvedValue(undefined), }, plansFactory: { findAll: jest.fn().mockResolvedValue(options.plans || [plan]), @@ -257,6 +258,83 @@ describe('PromoCodeService', () => { }); }); + describe('applyGrantPlan()', () => { + it('should not change workspace plan when usage reservation fails', async () => { + const planId = new ObjectId(); + const promoCode = createPromoCode({ + type: 'grant_plan', + planId, + }); + const workspace = { + _id: new ObjectId(), + tariffPlanId: new ObjectId(), + updatePlanHistory: jest.fn().mockResolvedValue(true), + updateLastChargeDate: jest.fn().mockResolvedValue(true), + changePlan: jest.fn().mockResolvedValue(1), + }; + const service = new PromoCodeService({ + promoCodesFactory: { + findByValue: 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(createPlan({ _id: planId })), + }, + } as any); + + await expectPromoError( + service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any), + PromoCodeErrorCode.LimitExceeded + ); + + expect(workspace.changePlan).not.toHaveBeenCalled(); + }); + + it('should rollback reserved usage when workspace plan change fails', async () => { + const planId = new ObjectId(); + const usageId = new ObjectId(); + const deleteById = jest.fn().mockResolvedValue(undefined); + const promoCode = createPromoCode({ + type: 'grant_plan', + planId, + }); + const workspace = { + _id: new ObjectId(), + tariffPlanId: new ObjectId(), + updatePlanHistory: jest.fn().mockResolvedValue(true), + updateLastChargeDate: jest.fn().mockResolvedValue(true), + changePlan: jest.fn().mockRejectedValue(new Error('change failed')), + }; + const service = new PromoCodeService({ + promoCodesFactory: { + findByValue: jest.fn().mockResolvedValue(promoCode), + }, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ _id: usageId }), + deleteById, + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(createPlan({ _id: planId })), + }, + } as any); + + await expectPromoError( + service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any), + PromoCodeErrorCode.ApplyFailed + ); + + expect(deleteById).toHaveBeenCalledWith(usageId); + }); + }); + describe('createUsage()', () => { it('should map duplicate usage creation to limit exceeded error', async () => { const promoCode = createPromoCode({ From f5b405f575466c23226a1e32c7958c863d9345e0 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:52:19 +0300 Subject: [PATCH 19/43] refactor(billing): simplify promo code retrieval and improve index initialization logic --- src/models/promoCodeUsagesFactory.ts | 28 +++++++++++++++------------- src/models/promoCodesFactory.ts | 8 +------- src/services/promoCodeService.ts | 6 ++++++ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index 14a77e14..6ecc48fb 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -118,19 +118,21 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - this.indexesPromise ??= Promise.all([ - this.collection.createIndex({ promoCodeId: 1 }), - this.collection.createIndex({ - promoCodeId: 1, - userId: 1, - }, { unique: true }), - this.collection.createIndex({ - promoCodeId: 1, - workspaceId: 1, - }, { unique: true }), - this.collection.createIndex({ workspaceId: 1 }), - this.collection.createIndex({ userId: 1 }), - ]).then(() => undefined); + if (!this.indexesPromise) { + this.indexesPromise = Promise.all([ + this.collection.createIndex({ promoCodeId: 1 }), + this.collection.createIndex({ + promoCodeId: 1, + userId: 1, + }, { unique: true }), + this.collection.createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { unique: true }), + this.collection.createIndex({ workspaceId: 1 }), + this.collection.createIndex({ userId: 1 }), + ]).then(() => undefined); + } await this.indexesPromise; } diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts index 530f670a..9508245c 100644 --- a/src/models/promoCodesFactory.ts +++ b/src/models/promoCodesFactory.ts @@ -35,13 +35,7 @@ export default class PromoCodesFactory extends AbstractModelFactory { await this.ensureIndexesOnce(); - const promoCode = await this.collection.findOne({ value }); - - if (!promoCode) { - return null; - } - - return new PromoCodeModel(promoCode); + return this.findOne({ value }); } /** diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index 32310470..f93f4e63 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -143,6 +143,7 @@ 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(); @@ -152,6 +153,7 @@ export function normalizePromoCodeValue(value: string): string { * 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); @@ -161,6 +163,7 @@ function isAllowedPromoValue(value: string): boolean { * Returns whether plan is available for purchase (not hidden). * * @param plan - tariff plan + * @returns whether plan can be selected for paid purchase or grant_plan promo */ function isPlanAvailableForPurchase(plan: PlanModel): boolean { return plan.isHidden !== true; @@ -171,6 +174,7 @@ function isPlanAvailableForPurchase(plan: PlanModel): boolean { * * @param benefit - promo benefit * @param plan - selected plan + * @returns whether benefit can be applied to the selected plan */ function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { if (benefit.type === 'grant_plan') { @@ -188,6 +192,7 @@ function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { * Returns whether discount promo can affect plan price. * * @param plan - tariff plan + * @returns whether plan is paid and available for purchase */ function isDiscountablePlan(plan: PlanModel): boolean { return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); @@ -198,6 +203,7 @@ function isDiscountablePlan(plan: PlanModel): boolean { * * @param benefit - promo benefit * @param plan - selected plan + * @returns calculated promo price for selected plan */ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: PlanModel): PromoCodePlanPrice { const originalAmount = plan.monthlyCharge; From 10f578e233746326874be79499dfca7cf2f7a22e Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:34:11 +0300 Subject: [PATCH 20/43] feat(billing): enhance promo validation and payment processing logic in CloudPayments integration --- src/billing/cloudpayments.ts | 78 ++-- src/resolvers/billingNew.ts | 12 +- src/services/promoCodeService.ts | 61 ++-- src/typeDefs/billing.ts | 7 +- src/utils/utm/utm.ts | 15 + test/billing/cloudpayments.test.ts | 473 ++++++++++++++++++++++++- test/directives/requireAdmin.test.ts | 106 ++++++ test/resolvers/billingNew.test.ts | 134 ++++++- test/services/promoCodeService.test.ts | 62 +++- test/utils/utm.test.ts | 17 +- 10 files changed, 874 insertions(+), 91 deletions(-) create mode 100644 test/directives/requireAdmin.test.ts diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 10c7121b..20c3fbeb 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -164,11 +164,10 @@ export default class CloudPaymentsWebhooks { let promoPricing; /** - * Record promo usage before applying paid benefits. + * Revalidate promo before accepting payment. * - * /pay runs after CloudPayments has accepted the charge, but workspace plan - * must not be changed if promo usage cannot be stored. Otherwise a transient - * DB/limit error would grant a discounted plan without consuming the promo. + * Amount check uses server-side pricing; usage is recorded later in /pay + * after workspace plan is updated successfully. */ if (data.promo && !data.isCardLinkOperation) { try { @@ -211,6 +210,21 @@ export default class CloudPaymentsWebhooks { return; } + 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 */ @@ -308,36 +322,6 @@ export default class CloudPaymentsWebhooks { return; } - if (data.promo && !data.isCardLinkOperation) { - try { - const promoCodeService = new PromoCodeService(req.context.factories); - const promoPricing = await promoCodeService.getPricingForPromoCodeId( - data.promo.id, - data.userId, - data.workspaceId, - tariffPlan - ); - - await promoCodeService.createUsage({ - promoCode: promoPricing.promoCode, - userId: data.userId, - workspaceId: workspace._id, - planId: tariffPlan._id, - benefitType: promoPricing.benefitType, - originalAmount: promoPricing.originalAmount, - finalAmount: promoPricing.finalAmount, - discountAmount: promoPricing.discountAmount, - utm: data.promo.utm, - }); - } catch (e) { - const error = e as Error; - - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body); - - return; - } - } - try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); @@ -371,6 +355,32 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo && !data.isCardLinkOperation) { + try { + const promoCodeService = new PromoCodeService(req.context.factories); + const promoPricing = await promoCodeService.getPricingForPromoCodeId( + data.promo.id, + data.userId, + data.workspaceId, + tariffPlan + ); + + await promoCodeService.createUsage({ + promoCode: promoPricing.promoCode, + userId: data.userId, + workspaceId: workspace._id, + planId: tariffPlan._id, + benefitType: promoPricing.benefitType, + originalAmount: promoPricing.originalAmount, + finalAmount: promoPricing.finalAmount, + discountAmount: promoPricing.discountAmount, + utm: data.promo.utm, + }); + } catch (error) { + console.error('[Billing / Pay] Failed to record promo usage after plan change', error); + } + } + // let accountId = workspace.accountId; /* diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 0a347730..55c9547b 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -17,7 +17,7 @@ import { TelegramBotURLs } from '../utils/telegram'; import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../services/promoCodeService'; import { publish } from '../rabbitmq'; import type { PaymentPromoData } from '../billing/types/paymentData'; -import { validateUtmParams } from '../utils/utm/utm'; +import { sanitizeUtmParams } from '../utils/utm/utm'; /** * The amount we will debit to confirm the subscription. @@ -108,7 +108,7 @@ async function previewOrApplyPromoCode( input.value, userId, workspace, - validateUtmParams(input.utm) + sanitizeUtmParams(input.utm) ); await notifyLimiterToUnblockWorkspace(workspace._id.toString()); @@ -171,6 +171,7 @@ export default { ): Promise<{ invoiceId: string; plan: { id: string; name: string; monthlyCharge: number }; + chargeAmount: number; isCardLinkOperation: boolean; currency: string; checksum: string; @@ -185,7 +186,7 @@ export default { }; }> { const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; - const promoUtm = validateUtmParams(input.promoUtm); + const promoUtm = sanitizeUtmParams(input.promoUtm); if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -294,8 +295,9 @@ debug: ${Boolean(workspace.isDebug)}` plan: { id: plan._id.toString(), name: plan.name, - monthlyCharge: paymentAmount, + monthlyCharge: plan.monthlyCharge, }, + chargeAmount: isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount, isCardLinkOperation, currency: 'RUB', checksum, @@ -472,6 +474,7 @@ debug: ${Boolean(workspace.isDebug)}` recurrent: { interval, period: 1, + amount: plan.monthlyCharge, }, }; @@ -481,7 +484,6 @@ debug: ${Boolean(workspace.isDebug)}` */ if (!isTariffPlanExpired) { jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString(); - jsonData.cloudPayments.recurrent.amount = planPaymentAmount; } } diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index f93f4e63..e8b8d026 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -10,6 +10,7 @@ import WorkspaceModel from '../models/workspace'; import { ContextFactories } from '../types/graphql'; import type { Utm } from '@hawk.so/types'; import type { PaymentPromoData } from '../billing/types/paymentData'; +import { sanitizeUtmParams } from '../utils/utm/utm'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; const DEFAULT_MIN_FINAL_PRICE = 1; @@ -347,9 +348,11 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { * @returns promo reference for payment checksum */ export function buildPaymentPromoData(promoCodeId: string, utm?: Utm): PaymentPromoData { + const sanitizedUtm = sanitizeUtmParams(utm); + return { id: promoCodeId, - ...(utm && Object.keys(utm).length > 0 ? { utm } : {}), + ...(sanitizedUtm ? { utm: sanitizedUtm } : {}), }; } @@ -502,16 +505,22 @@ export default class PromoCodeService { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); } + const now = new Date(); + + try { + await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); + await workspace.updateLastChargeDate(now); + await workspace.changePlan(plan._id); + } catch (error) { + if (error instanceof PromoCodeError) { + throw error; + } + + throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed'); + } + try { - const now = new Date(); - - /** - * Reserve usage before granting the plan. - * - * This makes promo usage a precondition for the benefit: if limits are exhausted - * or the insert fails, workspace state is not changed. - */ - const usage = await this.createUsage({ + await this.createUsage({ promoCode, userId, workspaceId: workspace._id, @@ -519,36 +528,18 @@ export default class PromoCodeService { benefitType: promoCode.benefit.type, utm, }); - - try { - await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); - await workspace.updateLastChargeDate(now); - await workspace.changePlan(plan._id); - } catch (error) { - try { - await this.factories.promoCodeUsagesFactory.deleteById(usage._id); - } catch (rollbackError) { - console.error('Failed to rollback promo usage after grant_plan apply failure', rollbackError); - } - - throw error; - } - - return plan; } catch (error) { - if (error instanceof PromoCodeError) { - throw error; - } - - throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed'); + console.error('[PromoCode] Failed to record promo usage after grant_plan apply', error); } + + return plan; } /** * Creates usage after successful payment or before immediate grant_plan apply. * - * Unique indexes on promoCodeId + userId/workspaceId make this method the durable - * reservation point. Callers should grant the promo benefit only after it succeeds. + * Unique indexes on promoCodeId + userId/workspaceId enforce one usage per user/workspace. + * Usage is recorded after plan change in CloudPayments /pay and grant_plan apply. * * @param params - usage creation params * @returns created promo usage @@ -566,6 +557,8 @@ export default class PromoCodeService { }): Promise { await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId); + const utm = sanitizeUtmParams(params.utm); + try { return await this.factories.promoCodeUsagesFactory.create({ promoCodeId: params.promoCode._id, @@ -577,7 +570,7 @@ export default class PromoCodeService { finalAmount: params.finalAmount, discountAmount: params.discountAmount, appliedAt: new Date(), - ...(params.utm && Object.keys(params.utm).length > 0 ? { utm: params.utm } : {}), + ...(utm ? { utm } : {}), }); } catch (error) { if ((error as { code?: number }).code === 11000) { diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 76b2291e..376a311c 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! } @@ -386,6 +386,11 @@ type ComposePaymentResponse { """ plan: ComposePaymentPlanInfo! + """ + Amount to charge for this payment (may differ from plan.monthlyCharge when promo is applied) + """ + chargeAmount: Int! + """ True if only card linking validation payment is expected """ diff --git a/src/utils/utm/utm.ts b/src/utils/utm/utm.ts index 61be7607..754ef682 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -62,3 +62,18 @@ export function validateUtmParams(utm: unknown): Utm | 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 index d7d981a0..fe0ca2bc 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -2,14 +2,18 @@ import '../../src/env-test'; jest.mock('cloudpayments', () => ({ ClientService: jest.fn().mockImplementation(() => ({ - getReceiptApi: jest.fn().mockReturnValue({}), - getClientApi: jest.fn().mockReturnValue({}), + getReceiptApi: jest.fn().mockReturnValue({ + createReceipt: jest.fn().mockResolvedValue(undefined), + }), + getClientApi: jest.fn().mockReturnValue({ + cancelSubscription: jest.fn().mockResolvedValue(undefined), + }), })), ReceiptTypes: { Income: 'Income', }, TaxationSystem: { - Common: 'Common', + SIMPLIFIED_INCOME: 'SIMPLIFIED_INCOME', }, })); @@ -21,13 +25,172 @@ jest.mock('../../src/mongo', () => ({ }, })); +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, OperationStatus, OperationType } from '../../src/billing/types/enums'; import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; +import { CheckCodes, PayCodes } from '../../src/billing/types'; import checksumService from '../../src/utils/checksumService'; +import { publish } from '../../src/rabbitmq'; 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 } : {}), + }); +} + +function createWebhookContext(options: { + workspaceId: string; + userId: string; + plan: ReturnType; + promoCode?: ReturnType | null; + createUsageImpl?: 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: 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 context = { + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue(workspace), + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(options.plan), + }, + usersFactory: { + findById: jest.fn().mockResolvedValue(user), + }, + businessOperationsFactory: { + create: jest.fn().mockResolvedValue(businessOperation), + 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, + 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(); @@ -83,4 +246,308 @@ describe('CloudPaymentsWebhooks', () => { }); }); }); + + describe('check()', () => { + 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 }); + }); + }); + + 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(); + }); + }); }); diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts new file mode 100644 index 00000000..739b337a --- /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 { + previewPromoCode(input: PreviewPromoCodeInput!): String! @requireAdmin + } + + input PreviewPromoCodeInput { + workspaceId: ID! + value: String! + } +`; + +const resolvers = { + Query: { + previewPromoCode: (): 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 PreviewPromoCode($input: PreviewPromoCodeInput!) { + previewPromoCode(input: $input) + } + `, + variableValues: { + input: { + workspaceId: context.workspaceId, + value: 'PROMO', + }, + }, + contextValue: context, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data?.previewPromoCode).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 PreviewPromoCode($input: PreviewPromoCodeInput!) { + previewPromoCode(input: $input) + } + `, + variableValues: { + input: { + workspaceId: context.workspaceId, + value: 'PROMO', + }, + }, + contextValue: context, + }); + + expect(result.errors?.[0]?.originalError).toBeInstanceOf(ForbiddenError); + }); +}); diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index 868c4a34..57703992 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -9,6 +9,13 @@ jest.mock('../../src/utils/telegram', () => ({ 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'; @@ -17,6 +24,7 @@ import { ResolverContextWithUser } from '../../src/types/graphql'; import checksumService from '../../src/utils/checksumService'; import { publish } from '../../src/rabbitmq'; 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'; @@ -235,6 +243,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(); @@ -340,7 +354,8 @@ describe('GraphQLBillingNew', () => { mockContext ); - expect(result.plan.monthlyCharge).toBe(750); + expect(result.plan.monthlyCharge).toBe(1000); + expect(result.chargeAmount).toBe(750); expect(result.promo).toMatchObject({ id: promoCodeId.toString(), benefitType: 'percent_discount', @@ -412,6 +427,7 @@ describe('GraphQLBillingNew', () => { expect(result.isCardLinkOperation).toBe(true); expect(result.plan.monthlyCharge).toBe(1000); + expect(result.chargeAmount).toBe(1); expect(result.promo).toBeUndefined(); }); }); @@ -565,4 +581,120 @@ describe('GraphQLBillingNew', () => { }); }); }); + + describe('payWithCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should charge discounted amount and set full recurrent amount from checksum promo id', async () => { + const promoCodeId = new ObjectId(); + 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(), + promo: { + id: promoCodeId.toString(), + }, + }); + 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: { + findOne: jest.fn().mockResolvedValue({ + _id: promoCodeId, + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + }), + } as any, + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + } 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: 750, + JsonData: expect.objectContaining({ + cloudPayments: expect.objectContaining({ + recurrent: expect.objectContaining({ + amount: 1000, + }), + }), + }), + }) + ); + }); + }); }) diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 3a8438be..2d373473 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -259,7 +259,7 @@ describe('PromoCodeService', () => { }); describe('applyGrantPlan()', () => { - it('should not change workspace plan when usage reservation fails', async () => { + it('should grant plan even when usage recording fails', async () => { const planId = new ObjectId(); const promoCode = createPromoCode({ type: 'grant_plan', @@ -272,6 +272,8 @@ describe('PromoCodeService', () => { updateLastChargeDate: jest.fn().mockResolvedValue(true), changePlan: jest.fn().mockResolvedValue(1), }; + const createUsage = jest.fn().mockRejectedValue({ code: 11000 }); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const service = new PromoCodeService({ promoCodesFactory: { findByValue: jest.fn().mockResolvedValue(promoCode), @@ -280,25 +282,29 @@ describe('PromoCodeService', () => { countByPromoCodeId: jest.fn().mockResolvedValue(0), findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - create: jest.fn().mockRejectedValue({ code: 11000 }), + create: createUsage, }, plansFactory: { findById: jest.fn().mockResolvedValue(createPlan({ _id: planId })), }, } as any); - await expectPromoError( - service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any), - PromoCodeErrorCode.LimitExceeded + const plan = await service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any); + + expect(plan._id).toEqual(planId); + expect(workspace.changePlan).toHaveBeenCalledWith(planId); + expect(createUsage).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[PromoCode] Failed to record promo usage after grant_plan apply', + expect.anything() ); - expect(workspace.changePlan).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); }); - it('should rollback reserved usage when workspace plan change fails', async () => { + it('should not record promo usage when workspace plan change fails', async () => { const planId = new ObjectId(); - const usageId = new ObjectId(); - const deleteById = jest.fn().mockResolvedValue(undefined); + const createUsage = jest.fn().mockResolvedValue({ _id: new ObjectId() }); const promoCode = createPromoCode({ type: 'grant_plan', planId, @@ -318,8 +324,7 @@ describe('PromoCodeService', () => { countByPromoCodeId: jest.fn().mockResolvedValue(0), findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - create: jest.fn().mockResolvedValue({ _id: usageId }), - deleteById, + create: createUsage, }, plansFactory: { findById: jest.fn().mockResolvedValue(createPlan({ _id: planId })), @@ -331,7 +336,7 @@ describe('PromoCodeService', () => { PromoCodeErrorCode.ApplyFailed ); - expect(deleteById).toHaveBeenCalledWith(usageId); + expect(createUsage).not.toHaveBeenCalled(); }); }); @@ -364,5 +369,38 @@ describe('PromoCodeService', () => { PromoCodeErrorCode.LimitExceeded ); }); + + it('should reject second createUsage when insert returns duplicate key', async () => { + const promoCode = createPromoCode({ + type: 'fixed_price', + amount: 100, + }); + const create = jest.fn() + .mockResolvedValueOnce({ _id: new ObjectId() }) + .mockRejectedValueOnce({ code: 11000 }); + const service = new PromoCodeService({ + promoCodeUsagesFactory: { + countByPromoCodeId: jest.fn().mockResolvedValue(0), + findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), + findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), + create, + }, + } as any); + const usageParams = { + promoCode, + userId: new ObjectId().toString(), + workspaceId: new ObjectId(), + planId: new ObjectId(), + benefitType: 'fixed_price' as const, + originalAmount: 1000, + finalAmount: 100, + discountAmount: 900, + }; + + await service.createUsage(usageParams); + + await expectPromoError(service.createUsage(usageParams), PromoCodeErrorCode.LimitExceeded); + expect(create).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/test/utils/utm.test.ts b/test/utils/utm.test.ts index fffbdb21..7643004f 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', + }); + }); + }); }); From 7945cdbbb65d46325c046f68e1ba03ea2f4d294b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:01:04 +0300 Subject: [PATCH 21/43] refactor(billing): remove unused index initialization logic from promo codes and usages factories --- .../20260615140000-add-promo-code-indexes.js | 29 ++++++++++++++ src/models/promoCodeUsagesFactory.ts | 39 ------------------- src/models/promoCodesFactory.ts | 21 ---------- 3 files changed, 29 insertions(+), 60 deletions(-) create mode 100644 migrations/20260615140000-add-promo-code-indexes.js diff --git a/migrations/20260615140000-add-promo-code-indexes.js b/migrations/20260615140000-add-promo-code-indexes.js new file mode 100644 index 00000000..9e289a99 --- /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/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index 6ecc48fb..4fdfa007 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -12,11 +12,6 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory; - /** - * Index creation promise. - */ - private indexesPromise?: Promise; - /** * Creates promo code usages factory instance. * @@ -33,8 +28,6 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - await this.ensureIndexesOnce(); - return this.collection.countDocuments({ promoCodeId }); } @@ -45,8 +38,6 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - await this.ensureIndexesOnce(); - const usage = await this.collection.findOne({ promoCodeId, userId, @@ -66,8 +57,6 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - await this.ensureIndexesOnce(); - const usage = await this.collection.findOne({ promoCodeId, workspaceId, @@ -86,8 +75,6 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory): Promise { - await this.ensureIndexesOnce(); - const usage = { _id: new ObjectId(), ...usageData, @@ -110,30 +97,4 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - if (!this.indexesPromise) { - this.indexesPromise = Promise.all([ - this.collection.createIndex({ promoCodeId: 1 }), - this.collection.createIndex({ - promoCodeId: 1, - userId: 1, - }, { unique: true }), - this.collection.createIndex({ - promoCodeId: 1, - workspaceId: 1, - }, { unique: true }), - this.collection.createIndex({ workspaceId: 1 }), - this.collection.createIndex({ userId: 1 }), - ]).then(() => undefined); - } - - await this.indexesPromise; - } } diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts index 9508245c..78570c0a 100644 --- a/src/models/promoCodesFactory.ts +++ b/src/models/promoCodesFactory.ts @@ -12,11 +12,6 @@ export default class PromoCodesFactory extends AbstractModelFactory; - /** - * Index creation promise. - */ - private indexesPromise?: Promise; - /** * Creates promo codes factory instance. * @@ -33,22 +28,6 @@ export default class PromoCodesFactory extends AbstractModelFactory { - await this.ensureIndexesOnce(); - return this.findOne({ value }); } - - /** - * Ensures promo code indexes exist before queries. - * - * MongoDB createIndex is idempotent: after API restart it reuses an existing index - * with the same keys/options and does not throw if the index is already present. - */ - private async ensureIndexesOnce(): Promise { - if (!this.indexesPromise) { - this.indexesPromise = this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined); - } - - await this.indexesPromise; - } } From d782e2b95898f0ff579ff6475ff380c0b1dd8958 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:52:10 +0300 Subject: [PATCH 22/43] refactor(billing): rename and restructure promo code application logic to improve clarity and remove deprecated methods --- src/models/promoCodeUsagesFactory.ts | 13 - src/resolvers/billingNew.ts | 80 +----- src/services/promoCodeService.ts | 323 +++++-------------------- src/typeDefs/billing.ts | 64 ++--- src/utils/promoCodePricing.ts | 150 ++++++++++++ test/directives/requireAdmin.test.ts | 16 +- test/resolvers/billingNew.test.ts | 67 ++--- test/services/promoCodeService.test.ts | 128 +++------- 8 files changed, 286 insertions(+), 555 deletions(-) create mode 100644 src/utils/promoCodePricing.ts diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index 4fdfa007..2e10213a 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -84,17 +84,4 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory { - await this.collection.deleteOne({ - _id: usageId, - }); - } } diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 55c9547b..80984a10 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -14,8 +14,7 @@ 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, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../services/promoCodeService'; -import { publish } from '../rabbitmq'; +import PromoCodeService, { PromoCodeApplyResult, PromoCodeError, PromoCodeErrorCode, buildPaymentPromoData } from '../services/promoCodeService'; import type { PaymentPromoData } from '../billing/types/paymentData'; import { sanitizeUtmParams } from '../utils/utm/utm'; @@ -39,13 +38,12 @@ interface ComposePaymentArgs { } /** - * Input data for promo code preview/apply mutation. + * Input data for promo code apply mutation. */ -interface PreviewPromoCodeArgs { +interface ApplyPromoCodeArgs { input: { workspaceId: string; value: string; - utm?: Utm; }; } @@ -62,63 +60,6 @@ function throwPromoCodeGraphQLError(error: unknown): never { throw new UserInputError(PromoCodeErrorCode.ApplyFailed); } -/** - * Sends task to limiter worker to unblock workspace after plan became valid again. - * Same mechanism is used after successful payment or manual plan change. - * - * @param workspaceId - workspace id to unblock - */ -async function notifyLimiterToUnblockWorkspace(workspaceId: string): Promise { - await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ - type: 'unblock-workspace', - workspaceId, - })); -} - -/** - * Validates promo code and either returns discount preview or applies grant_plan promo. - * - * Discount promos: returns recalculated plan prices with applied: false, no side effects. - * Grant plan promo: applies plan immediately, stores usage, then unblocks workspace in limiter. - * Unblock is not caused by preview itself — it runs only after grant_plan apply, - * because workspace received a valid plan the same way as after paid plan change. - * - * @param promoCodeService - promo code service instance - * @param input - promo code mutation input - * @param userId - current user id - * @param workspace - workspace model - * @returns promo preview or apply result - */ -async function previewOrApplyPromoCode( - promoCodeService: PromoCodeService, - input: PreviewPromoCodeArgs['input'], - userId: string, - workspace: WorkspaceModel -): Promise { - const promoPreview = await promoCodeService.preview(input.value, userId, input.workspaceId); - - if (promoPreview.benefitType !== 'grant_plan') { - return { - ...promoPreview, - applied: false, - }; - } - - await promoCodeService.applyGrantPlan( - input.value, - userId, - workspace, - sanitizeUtmParams(input.utm) - ); - - await notifyLimiterToUnblockWorkspace(workspace._id.toString()); - - return { - ...promoPreview, - applied: true, - }; -} - /** * Data for processing payment with saved card */ @@ -377,12 +318,7 @@ debug: ${Boolean(workspace.isDebug)}` Mutation: { /** - * Validates promo code for workspace and returns calculated prices. - * - * Preview here means a dry-run for discount promos: server recalculates prices - * for all visible plans and returns them without creating promo usage. - * For grant_plan promo preview becomes apply: workspace plan is changed immediately, - * usage is stored, and response contains applied: true. + * Validates promo code for workspace and returns benefit data for client-side pricing. * * Access check is handled by @requireAdmin on GraphQL schema. * @@ -391,11 +327,11 @@ debug: ${Boolean(workspace.isDebug)}` * @param user - current authorized user * @param factories - factories for working with models */ - async previewPromoCode( + async applyPromoCode( _obj: undefined, - { input }: PreviewPromoCodeArgs, + { input }: ApplyPromoCodeArgs, { user, factories }: ResolverContextWithUser - ): Promise { + ): Promise { const workspace = await factories.workspacesFactory.findById(input.workspaceId); if (!workspace) { @@ -405,7 +341,7 @@ debug: ${Boolean(workspace.isDebug)}` const promoCodeService = new PromoCodeService(factories); try { - return await previewOrApplyPromoCode(promoCodeService, input, user.id, workspace); + return await promoCodeService.applyPromoCode(input.value, user.id, input.workspaceId); } catch (error) { throwPromoCodeGraphQLError(error); } diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index e8b8d026..ede09add 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -1,15 +1,14 @@ import { ObjectId } from 'mongodb'; -import { - PromoCodeBenefit, - PromoCodeBenefitType -} from '@hawk.so/types'; +import { PromoCodeBenefitType } from '@hawk.so/types'; import PlanModel from '../models/plan'; import PromoCodeModel from '../models/promoCode'; import PromoCodeUsageModel from '../models/promoCodeUsage'; -import WorkspaceModel from '../models/workspace'; 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_-]+$/; @@ -45,36 +44,6 @@ export class PromoCodeError extends Error { } } -/** - * Price calculated for a plan after promo preview. - */ -export interface PromoCodePlanPrice { - /** - * Plan id. - */ - planId: string; - - /** - * Whether promo code can be applied to this plan. - */ - isApplicable: boolean; - - /** - * Plan price before promo. - */ - originalAmount: number; - - /** - * Plan price after promo. - */ - finalAmount: number; - - /** - * Actual discount in money. - */ - discountAmount: number; -} - /** * Validated promo data for one selected plan. */ @@ -106,9 +75,9 @@ export interface PromoCodePricingResult { } /** - * Promo preview result for all plans. + * Validated promo code data returned after apply. */ -export interface PromoCodePreviewResult { +export interface PromoCodeApplyResult { /** * Normalized promo value. */ @@ -125,14 +94,19 @@ export interface PromoCodePreviewResult { percent?: number; /** - * Discount amount or fixed price amount. + * Fixed price amount. */ amount?: number; /** - * Calculated price for each visible plan. + * Minimum final price after percent discount. + */ + minFinalPrice?: number; + + /** + * Plan ids this promo can be applied to. */ - plans: PromoCodePlanPrice[]; + applicablePlanIds?: string[]; } /** @@ -160,143 +134,18 @@ function isAllowedPromoValue(value: string): boolean { return Boolean(value) && PROMO_CODE_REGEXP.test(value); } -/** - * Returns whether plan is available for purchase (not hidden). - * - * @param plan - tariff plan - * @returns whether plan can be selected for paid purchase or grant_plan promo - */ -function isPlanAvailableForPurchase(plan: PlanModel): boolean { - return plan.isHidden !== true; +function isSupportedPromoCodeBenefitType(type: PromoCodeBenefitType): boolean { + return type === 'percent_discount' || type === 'fixed_price'; } /** - * Checks whether promo benefit is applicable to plan. + * Rejects benefit types that are defined in schema but not implemented yet. * * @param benefit - promo benefit - * @param plan - selected plan - * @returns whether benefit can be applied to the selected plan */ -function isPlanApplicable(benefit: PromoCodeBenefit, plan: PlanModel): boolean { - if (benefit.type === 'grant_plan') { - return benefit.planId?.toString() === plan._id.toString(); - } - - if (!benefit.applicablePlanIds || benefit.applicablePlanIds.length === 0) { - return true; - } - - return benefit.applicablePlanIds.some((planId): boolean => planId.toString() === plan._id.toString()); -} - -/** - * Returns whether discount promo can affect plan price. - * - * @param plan - tariff plan - * @returns whether plan is paid and available for purchase - */ -function isDiscountablePlan(plan: PlanModel): boolean { - return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); -} - -/** - * Calculates discounted price for one plan. - * - * @param benefit - promo benefit - * @param plan - selected plan - * @returns calculated promo price for selected plan - */ -export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: PlanModel): PromoCodePlanPrice { - const originalAmount = plan.monthlyCharge; - const isApplicable = benefit.type !== 'grant_plan' && - isDiscountablePlan(plan) && - isPlanApplicable(benefit, plan); - - if (!isApplicable) { - return { - planId: plan._id.toString(), - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; - } - - 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) { - return { - planId: plan._id.toString(), - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; - } - - return { - planId: plan._id.toString(), - isApplicable: true, - originalAmount, - finalAmount, - discountAmount: originalAmount - finalAmount, - }; - } - - case 'amount_discount': { - const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; - const finalAmount = Math.max(originalAmount - benefit.amount, minFinalPrice); - - if (finalAmount >= originalAmount) { - return { - planId: plan._id.toString(), - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; - } - - return { - planId: plan._id.toString(), - isApplicable: true, - originalAmount, - finalAmount, - discountAmount: originalAmount - finalAmount, - }; - } - - case 'fixed_price': - if (benefit.amount >= originalAmount) { - return { - planId: plan._id.toString(), - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; - } - - return { - planId: plan._id.toString(), - isApplicable: true, - originalAmount, - finalAmount: benefit.amount, - discountAmount: originalAmount - benefit.amount, - }; - - default: - return { - planId: plan._id.toString(), - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; +function assertSupportedBenefitType(benefit: PromoCodeModel['benefit']): void { + if (!isSupportedPromoCodeBenefitType(benefit.type)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); } } @@ -305,15 +154,8 @@ export function calculatePromoCodePlanPrice(benefit: PromoCodeBenefit, plan: Pla * * @param benefit - promo benefit */ -function validateBenefitStructure(benefit: PromoCodeBenefit): void { - switch (benefit?.type) { - case 'grant_plan': - if (!benefit.planId) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan id is missing'); - } - - return; - +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'); @@ -321,13 +163,6 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { return; - case 'amount_discount': - if (typeof benefit.amount !== 'number' || benefit.amount <= 0) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Amount 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'); @@ -336,6 +171,10 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void { 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'); } } @@ -429,45 +268,41 @@ export default class PromoCodeService { } /** - * Builds preview prices for visible plans. - * - * Must run on the API: promo terms and limits live in the database and are re-checked - * on composePayment/check/pay. The frontend only sends promo id in the payment checksum; - * preview is the user-facing step that validates the code and returns prices to display. - * Pure price math is in {@link calculatePromoCodePlanPrice}; this method adds DB lookup, - * expiry/usage limits, and grant_plan availability checks. + * Validates 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 preview(value: string, userId: string, workspaceId: string): Promise { + public async applyPromoCode(value: string, userId: string, workspaceId: string): Promise { const promoCode = await this.getValidPromoCode(value, userId, workspaceId); const benefit = promoCode.benefit; - if (benefit.type === 'grant_plan') { - const plan = await this.factories.plansFactory.findById(benefit.planId.toString()); + const result: PromoCodeApplyResult = { + value: promoCode.value, + benefitType: benefit.type, + }; + + if (benefit.type === 'percent_discount') { + result.percent = benefit.percent; - if (!plan || !isPlanAvailableForPurchase(plan)) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); + if (benefit.minFinalPrice !== undefined) { + result.minFinalPrice = benefit.minFinalPrice; } + } - return { - value: promoCode.value, - benefitType: benefit.type, - plans: [], - }; + if (benefit.type === 'fixed_price') { + result.amount = benefit.amount; } - const plans = await this.factories.plansFactory.findAll(); + if ( + (benefit.type === 'percent_discount' || benefit.type === 'fixed_price') && + benefit.applicablePlanIds?.length + ) { + result.applicablePlanIds = benefit.applicablePlanIds.map((planId): string => planId.toString()); + } - return { - value: promoCode.value, - benefitType: benefit.type, - percent: benefit.type === 'percent_discount' ? benefit.percent : undefined, - amount: benefit.type === 'amount_discount' || benefit.type === 'fixed_price' ? benefit.amount : undefined, - plans: plans.map((plan): PromoCodePlanPrice => calculatePromoCodePlanPrice(benefit, plan)), - }; + return result; } /** @@ -485,61 +320,10 @@ export default class PromoCodeService { } /** - * Applies grant_plan promo code to workspace. - * - * @param value - raw promo code value - * @param userId - user id - * @param workspace - workspace model - * @param utm - optional UTM data - */ - public async applyGrantPlan(value: string, userId: string, workspace: WorkspaceModel, utm?: PromoCodeUtm): Promise { - const promoCode = await this.getValidPromoCode(value, userId, workspace._id.toString()); - - if (promoCode.benefit.type !== 'grant_plan') { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not grant_plan'); - } - - const plan = await this.factories.plansFactory.findById(promoCode.benefit.planId.toString()); - - if (!plan || !isPlanAvailableForPurchase(plan)) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable'); - } - - const now = new Date(); - - try { - await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); - await workspace.updateLastChargeDate(now); - await workspace.changePlan(plan._id); - } catch (error) { - if (error instanceof PromoCodeError) { - throw error; - } - - throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed'); - } - - try { - await this.createUsage({ - promoCode, - userId, - workspaceId: workspace._id, - planId: plan._id, - benefitType: promoCode.benefit.type, - utm, - }); - } catch (error) { - console.error('[PromoCode] Failed to record promo usage after grant_plan apply', error); - } - - return plan; - } - - /** - * Creates usage after successful payment or before immediate grant_plan apply. + * Creates usage after successful payment. * * Unique indexes on promoCodeId + userId/workspaceId enforce one usage per user/workspace. - * Usage is recorded after plan change in CloudPayments /pay and grant_plan apply. + * Usage is recorded after plan change in CloudPayments /pay. * * @param params - usage creation params * @returns created promo usage @@ -597,6 +381,7 @@ export default class PromoCodeService { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); } + assertSupportedBenefitType(promoCode.benefit); validateBenefitStructure(promoCode.benefit); await this.validateUsageLimits(promoCode, userId, new ObjectId(workspaceId)); } @@ -609,11 +394,13 @@ export default class PromoCodeService { * @returns validated promo pricing for selected plan */ private buildPricingResult(promoCode: PromoCodeModel, plan: PlanModel): PromoCodePricingResult { - if (promoCode.benefit.type === 'grant_plan') { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan promo cannot be used in payment'); + 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(promoCode.benefit, plan); + const price = calculatePromoCodePlanPrice(benefit, plan); if (!price.isApplicable) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not applicable to selected plan'); diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 376a311c..78f3b16f 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -248,9 +248,9 @@ input ComposePaymentInput { } """ -Input for promo code preview/apply +Input for promo code apply """ -input PreviewPromoCodeInput { +input ApplyPromoCodeInput { """ Workspace id for which promo code is applied """ @@ -260,11 +260,6 @@ input PreviewPromoCodeInput { Promo code value entered by user """ value: String! - - """ - UTM parameters captured when promo code was applied - """ - utm: UtmInput } """ @@ -278,39 +273,9 @@ enum PromoCodeBenefitType { } """ -Calculated promo code price for a tariff plan -""" -type PromoCodePlanPrice { - """ - Plan id - """ - planId: ID! - - """ - Whether promo code can be applied to this plan - """ - isApplicable: Boolean! - - """ - Plan price before promo - """ - originalAmount: Int! - - """ - Plan price after promo - """ - finalAmount: Int! - - """ - Actual discount amount in money - """ - discountAmount: Int! -} - -""" -Promo code preview response +Validated promo code data for client-side price calculation """ -type PreviewPromoCodeResponse { +type ApplyPromoCodeResponse { """ Normalized promo code value """ @@ -321,25 +286,25 @@ type PreviewPromoCodeResponse { """ benefitType: PromoCodeBenefitType! - """ - True if grant_plan promo was applied immediately - """ - applied: Boolean! - """ Discount percent for percent promos """ percent: Int """ - Discount or fixed price amount + Fixed price amount """ amount: Int """ - Calculated prices for visible plans + Minimum final price after percent discount + """ + minFinalPrice: Int + + """ + Plan ids this promo can be applied to """ - plans: [PromoCodePlanPrice!]! + applicablePlanIds: [ID!] } """ @@ -472,10 +437,9 @@ type PayWithCardResponse { extend type Mutation { """ - Validates promo code for workspace admin and returns calculated prices, - or immediately applies grant_plan promo + Validates promo code for workspace admin and returns benefit data """ - previewPromoCode(input: PreviewPromoCodeInput!): PreviewPromoCodeResponse! @requireAdmin + applyPromoCode(input: ApplyPromoCodeInput!): ApplyPromoCodeResponse! @requireAdmin """ Remove card diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts new file mode 100644 index 00000000..e0927323 --- /dev/null +++ b/src/utils/promoCodePricing.ts @@ -0,0 +1,150 @@ +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 isApplicable = isDiscountablePlan(plan) && isPlanApplicable(benefit, plan); + + if (!isApplicable) { + return { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + + 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) { + return { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + + return { + planId, + isApplicable: true, + originalAmount, + finalAmount, + discountAmount: originalAmount - finalAmount, + }; + } + + case 'fixed_price': + if (benefit.amount >= originalAmount) { + return { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } + + return { + planId, + isApplicable: true, + originalAmount, + finalAmount: benefit.amount, + discountAmount: originalAmount - benefit.amount, + }; + + default: + return { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + } +} diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts index 739b337a..08cd3200 100644 --- a/test/directives/requireAdmin.test.ts +++ b/test/directives/requireAdmin.test.ts @@ -12,10 +12,10 @@ const typeDefs = ` ${requireAdminDirectiveTypeDefs} type Query { - previewPromoCode(input: PreviewPromoCodeInput!): String! @requireAdmin + applyPromoCode(input: ApplyPromoCodeInput!): String! @requireAdmin } - input PreviewPromoCodeInput { + input ApplyPromoCodeInput { workspaceId: ID! value: String! } @@ -23,7 +23,7 @@ const typeDefs = ` const resolvers = { Query: { - previewPromoCode: (): string => 'ok', + applyPromoCode: (): string => 'ok', }, }; @@ -65,8 +65,8 @@ describe('requireAdmin directive', () => { const result = await graphql({ schema, source: ` - query PreviewPromoCode($input: PreviewPromoCodeInput!) { - previewPromoCode(input: $input) + query ApplyPromoCode($input: ApplyPromoCodeInput!) { + applyPromoCode(input: $input) } `, variableValues: { @@ -79,7 +79,7 @@ describe('requireAdmin directive', () => { }); expect(result.errors).toBeUndefined(); - expect(result.data?.previewPromoCode).toBe('ok'); + expect(result.data?.applyPromoCode).toBe('ok'); }); it('should reject mutation when user is not workspace admin via input.workspaceId', async () => { @@ -88,8 +88,8 @@ describe('requireAdmin directive', () => { const result = await graphql({ schema, source: ` - query PreviewPromoCode($input: PreviewPromoCodeInput!) { - previewPromoCode(input: $input) + query ApplyPromoCode($input: ApplyPromoCodeInput!) { + applyPromoCode(input: $input) } `, variableValues: { diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index 57703992..9489d418 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -22,7 +22,6 @@ 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 { publish } from '../../src/rabbitmq'; import { PromoCodeErrorCode } from '../../src/services/promoCodeService'; import cloudPaymentsApi from '../../src/utils/cloudPaymentsApi'; @@ -146,9 +145,9 @@ function withPromoFactories( } /** - * Creates test data and mocks for previewPromoCode tests. + * Creates test data and mocks for applyPromoCode tests. */ -function createPreviewPromoCodeTestSetup(options: { +function createApplyPromoCodeTestSetup(options: { promoCode: Record | null; grantPlan?: PlanDBScheme; }): { @@ -432,14 +431,14 @@ describe('GraphQLBillingNew', () => { }); }); - describe('previewPromoCode', () => { + describe('applyPromoCode', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should return discount preview without applying grant plan side effects', async () => { + it('should return benefit data without side effects', async () => { const promoCodeId = new ObjectId(); - const { mockContext, workspaceId, workspaceMock } = createPreviewPromoCodeTestSetup({ + const { mockContext, workspaceId, workspaceMock } = createApplyPromoCodeTestSetup({ promoCode: { _id: promoCodeId, value: 'SAVE25', @@ -450,7 +449,7 @@ describe('GraphQLBillingNew', () => { }, }); - const result = await billingNewResolver.Mutation.previewPromoCode( + const result = await billingNewResolver.Mutation.applyPromoCode( undefined, { input: { @@ -462,24 +461,17 @@ describe('GraphQLBillingNew', () => { ); expect(result).toMatchObject({ - applied: false, + value: 'SAVE25', benefitType: 'percent_discount', percent: 25, - plans: [{ - isApplicable: true, - originalAmount: 1000, - finalAmount: 750, - discountAmount: 250, - }], }); expect(workspaceMock.changePlan).not.toHaveBeenCalled(); - expect(publish).not.toHaveBeenCalled(); }); - it('should apply grant_plan promo and publish workspace unblock', async () => { + it('should reject unsupported grant_plan promo', async () => { const promoCodeId = new ObjectId(); const grantPlanId = new ObjectId(); - const { mockContext, workspaceId, workspaceMock } = createPreviewPromoCodeTestSetup({ + const { mockContext, workspaceId } = createApplyPromoCodeTestSetup({ promoCode: { _id: promoCodeId, value: 'GRANT', @@ -499,31 +491,18 @@ describe('GraphQLBillingNew', () => { }, }); - const result = await billingNewResolver.Mutation.previewPromoCode( - undefined, - { - input: { - workspaceId, - value: 'grant', + await expect( + billingNewResolver.Mutation.applyPromoCode( + undefined, + { + input: { + workspaceId, + value: 'grant', + }, }, - }, - mockContext - ); - - expect(result).toMatchObject({ - applied: true, - benefitType: 'grant_plan', - plans: [], - }); - expect(workspaceMock.changePlan).toHaveBeenCalledWith(grantPlanId); - expect(publish).toHaveBeenCalledWith( - 'cron-tasks', - 'cron-tasks/limiter', - JSON.stringify({ - type: 'unblock-workspace', - workspaceId, - }) - ); + mockContext + ) + ).rejects.toBeInstanceOf(UserInputError); }); it('should reject unknown workspace', async () => { @@ -547,7 +526,7 @@ describe('GraphQLBillingNew', () => { }; await expect( - billingNewResolver.Mutation.previewPromoCode( + billingNewResolver.Mutation.applyPromoCode( undefined, { input: { @@ -561,12 +540,12 @@ describe('GraphQLBillingNew', () => { }); it('should map promo validation errors to public codes', async () => { - const { mockContext, workspaceId } = createPreviewPromoCodeTestSetup({ + const { mockContext, workspaceId } = createApplyPromoCodeTestSetup({ promoCode: null, }); await expect( - billingNewResolver.Mutation.previewPromoCode( + billingNewResolver.Mutation.applyPromoCode( undefined, { input: { diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 2d373473..c2c49504 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -1,10 +1,10 @@ import { ObjectId } from 'mongodb'; import PromoCodeService, { - calculatePromoCodePlanPrice, normalizePromoCodeValue, PromoCodeError, PromoCodeErrorCode } from '../../src/services/promoCodeService'; +import { calculatePromoCodePlanPrice } from '../../src/utils/promoCodePricing'; function createPlan(overrides: Record = {}) { return { @@ -49,7 +49,6 @@ function createService(promoCode: any, options: { findByPromoCodeAndUser: jest.fn().mockResolvedValue(options.userUsage ?? null), findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(options.workspaceUsage ?? null), create: jest.fn().mockResolvedValue({ _id: new ObjectId() }), - deleteById: jest.fn().mockResolvedValue(undefined), }, plansFactory: { findAll: jest.fn().mockResolvedValue(options.plans || [plan]), @@ -88,18 +87,6 @@ describe('PromoCodeService', () => { }); }); - it('should apply amount discount with min final price cap', () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const price = calculatePromoCodePlanPrice({ - type: 'amount_discount', - amount: 1200, - minFinalPrice: 150, - } as any, plan); - - expect(price.finalAmount).toBe(150); - expect(price.discountAmount).toBe(850); - }); - it('should apply fixed price promo', () => { const plan = createPlan({ monthlyCharge: 1000 }); const price = calculatePromoCodePlanPrice({ @@ -156,8 +143,8 @@ describe('PromoCodeService', () => { }); }); - describe('preview()', () => { - it('should return preview for percent discount promo', async () => { + describe('applyPromoCode()', () => { + it('should return benefit data for percent discount promo', async () => { const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ type: 'percent_discount', @@ -165,25 +152,20 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { plan }); - const preview = await service.preview(' promo ', new ObjectId().toString(), new ObjectId().toString()); + const result = await service.applyPromoCode(' promo ', new ObjectId().toString(), new ObjectId().toString()); - expect(preview).toMatchObject({ + expect(result).toMatchObject({ value: 'PROMO', benefitType: 'percent_discount', percent: 25, - plans: [{ - isApplicable: true, - originalAmount: 1000, - finalAmount: 750, - discountAmount: 250, - }], }); + expect(result.applicablePlanIds).toBeUndefined(); }); it('should reject unknown promo code', async () => { const service = createService(null); - await expectPromoError(service.preview('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.applyPromoCode('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); it('should reject expired promo code', async () => { @@ -195,7 +177,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode); - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); it('should reject total usage limit', async () => { @@ -207,7 +189,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { totalUses: 1 }); - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject user usage limit', async () => { @@ -217,7 +199,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { userUsage: {} }); - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject workspace usage limit', async () => { @@ -227,7 +209,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { workspaceUsage: {} }); - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject invalid benefit structure', async () => { @@ -237,17 +219,16 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode); - await expectPromoError(service.preview('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); }); describe('getPricingForPlan()', () => { - it('should reject selected plan when promo is not applicable', async () => { + it('should reject unsupported amount_discount promo', async () => { const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ type: 'amount_discount', amount: 100, - applicablePlanIds: [new ObjectId()], }); const service = createService(promoCode); @@ -256,87 +237,34 @@ describe('PromoCodeService', () => { PromoCodeErrorCode.Invalid ); }); - }); - describe('applyGrantPlan()', () => { - it('should grant plan even when usage recording fails', async () => { - const planId = new ObjectId(); + it('should reject unsupported grant_plan promo', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ type: 'grant_plan', - planId, + planId: new ObjectId(), }); - const workspace = { - _id: new ObjectId(), - tariffPlanId: new ObjectId(), - updatePlanHistory: jest.fn().mockResolvedValue(true), - updateLastChargeDate: jest.fn().mockResolvedValue(true), - changePlan: jest.fn().mockResolvedValue(1), - }; - const createUsage = jest.fn().mockRejectedValue({ code: 11000 }); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); - const service = new PromoCodeService({ - promoCodesFactory: { - findByValue: jest.fn().mockResolvedValue(promoCode), - }, - promoCodeUsagesFactory: { - countByPromoCodeId: jest.fn().mockResolvedValue(0), - findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), - findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - create: createUsage, - }, - plansFactory: { - findById: jest.fn().mockResolvedValue(createPlan({ _id: planId })), - }, - } as any); - - const plan = await service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any); + const service = createService(promoCode); - expect(plan._id).toEqual(planId); - expect(workspace.changePlan).toHaveBeenCalledWith(planId); - expect(createUsage).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[PromoCode] Failed to record promo usage after grant_plan apply', - expect.anything() + await expectPromoError( + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid ); - - consoleErrorSpy.mockRestore(); }); - it('should not record promo usage when workspace plan change fails', async () => { - const planId = new ObjectId(); - const createUsage = jest.fn().mockResolvedValue({ _id: new ObjectId() }); + it('should reject selected plan when promo is not applicable', async () => { + const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ - type: 'grant_plan', - planId, + type: 'percent_discount', + percent: 10, + applicablePlanIds: [new ObjectId()], }); - const workspace = { - _id: new ObjectId(), - tariffPlanId: new ObjectId(), - updatePlanHistory: jest.fn().mockResolvedValue(true), - updateLastChargeDate: jest.fn().mockResolvedValue(true), - changePlan: jest.fn().mockRejectedValue(new Error('change failed')), - }; - const service = new PromoCodeService({ - promoCodesFactory: { - findByValue: jest.fn().mockResolvedValue(promoCode), - }, - promoCodeUsagesFactory: { - countByPromoCodeId: jest.fn().mockResolvedValue(0), - findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), - findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - create: createUsage, - }, - plansFactory: { - findById: jest.fn().mockResolvedValue(createPlan({ _id: planId })), - }, - } as any); + const service = createService(promoCode); await expectPromoError( - service.applyGrantPlan('grant', new ObjectId().toString(), workspace as any), - PromoCodeErrorCode.ApplyFailed + service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), + PromoCodeErrorCode.Invalid ); - - expect(createUsage).not.toHaveBeenCalled(); }); }); From e9ed5614127d26bbf5a0ee920bebd843f1590890 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:46:04 +0300 Subject: [PATCH 23/43] refactor(billing): rename applyPromoCode to verifyPromoCode and update related types and logic for clarity --- src/resolvers/billingNew.ts | 18 +++++++++--------- src/services/promoCodeService.ts | 12 ++++++------ src/typeDefs/billing.ts | 12 ++++++------ test/directives/requireAdmin.test.ts | 16 ++++++++-------- test/resolvers/billingNew.test.ts | 20 ++++++++++---------- test/services/promoCodeService.test.ts | 16 ++++++++-------- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 80984a10..5d11b16c 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -14,7 +14,7 @@ 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, { PromoCodeApplyResult, PromoCodeError, PromoCodeErrorCode, buildPaymentPromoData } from '../services/promoCodeService'; +import PromoCodeService, { PromoCodeVerifyResult, PromoCodeError, PromoCodeErrorCode, buildPaymentPromoData } from '../services/promoCodeService'; import type { PaymentPromoData } from '../billing/types/paymentData'; import { sanitizeUtmParams } from '../utils/utm/utm'; @@ -38,9 +38,9 @@ interface ComposePaymentArgs { } /** - * Input data for promo code apply mutation. + * Input data for promo code verification mutation. */ -interface ApplyPromoCodeArgs { +interface VerifyPromoCodeArgs { input: { workspaceId: string; value: string; @@ -57,7 +57,7 @@ function throwPromoCodeGraphQLError(error: unknown): never { throw new UserInputError(error.code); } - throw new UserInputError(PromoCodeErrorCode.ApplyFailed); + throw new UserInputError(PromoCodeErrorCode.VerifyFailed); } /** @@ -318,7 +318,7 @@ debug: ${Boolean(workspace.isDebug)}` Mutation: { /** - * Validates promo code for workspace and returns benefit data for client-side pricing. + * Verifies promo code for workspace and returns benefit data for client-side pricing. * * Access check is handled by @requireAdmin on GraphQL schema. * @@ -327,11 +327,11 @@ debug: ${Boolean(workspace.isDebug)}` * @param user - current authorized user * @param factories - factories for working with models */ - async applyPromoCode( + async verifyPromoCode( _obj: undefined, - { input }: ApplyPromoCodeArgs, + { input }: VerifyPromoCodeArgs, { user, factories }: ResolverContextWithUser - ): Promise { + ): Promise { const workspace = await factories.workspacesFactory.findById(input.workspaceId); if (!workspace) { @@ -341,7 +341,7 @@ debug: ${Boolean(workspace.isDebug)}` const promoCodeService = new PromoCodeService(factories); try { - return await promoCodeService.applyPromoCode(input.value, user.id, input.workspaceId); + return await promoCodeService.verifyPromoCode(input.value, user.id, input.workspaceId); } catch (error) { throwPromoCodeGraphQLError(error); } diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index ede09add..386d5ce3 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -20,7 +20,7 @@ const DEFAULT_MIN_FINAL_PRICE = 1; export enum PromoCodeErrorCode { Invalid = 'PROMO_CODE_INVALID', LimitExceeded = 'PROMO_CODE_LIMIT_EXCEEDED', - ApplyFailed = 'PROMO_CODE_APPLY_FAILED', + VerifyFailed = 'PROMO_CODE_VERIFY_FAILED', } /** @@ -75,9 +75,9 @@ export interface PromoCodePricingResult { } /** - * Validated promo code data returned after apply. + * Validated promo code data returned after verification. */ -export interface PromoCodeApplyResult { +export interface PromoCodeVerifyResult { /** * Normalized promo value. */ @@ -268,17 +268,17 @@ export default class PromoCodeService { } /** - * Validates promo code and returns benefit data for client-side price calculation. + * 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 applyPromoCode(value: string, userId: string, workspaceId: string): Promise { + public async verifyPromoCode(value: string, userId: string, workspaceId: string): Promise { const promoCode = await this.getValidPromoCode(value, userId, workspaceId); const benefit = promoCode.benefit; - const result: PromoCodeApplyResult = { + const result: PromoCodeVerifyResult = { value: promoCode.value, benefitType: benefit.type, }; diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 78f3b16f..11b8c336 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -248,9 +248,9 @@ input ComposePaymentInput { } """ -Input for promo code apply +Input for promo code verification """ -input ApplyPromoCodeInput { +input VerifyPromoCodeInput { """ Workspace id for which promo code is applied """ @@ -273,9 +273,9 @@ enum PromoCodeBenefitType { } """ -Validated promo code data for client-side price calculation +Verified promo code data for client-side price calculation """ -type ApplyPromoCodeResponse { +type VerifyPromoCodeResponse { """ Normalized promo code value """ @@ -437,9 +437,9 @@ type PayWithCardResponse { extend type Mutation { """ - Validates promo code for workspace admin and returns benefit data + Verifies promo code for workspace admin and returns benefit data """ - applyPromoCode(input: ApplyPromoCodeInput!): ApplyPromoCodeResponse! @requireAdmin + verifyPromoCode(input: VerifyPromoCodeInput!): VerifyPromoCodeResponse! @requireAdmin """ Remove card diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts index 08cd3200..c59e7158 100644 --- a/test/directives/requireAdmin.test.ts +++ b/test/directives/requireAdmin.test.ts @@ -12,10 +12,10 @@ const typeDefs = ` ${requireAdminDirectiveTypeDefs} type Query { - applyPromoCode(input: ApplyPromoCodeInput!): String! @requireAdmin + verifyPromoCode(input: VerifyPromoCodeInput!): String! @requireAdmin } - input ApplyPromoCodeInput { + input VerifyPromoCodeInput { workspaceId: ID! value: String! } @@ -23,7 +23,7 @@ const typeDefs = ` const resolvers = { Query: { - applyPromoCode: (): string => 'ok', + verifyPromoCode: (): string => 'ok', }, }; @@ -65,8 +65,8 @@ describe('requireAdmin directive', () => { const result = await graphql({ schema, source: ` - query ApplyPromoCode($input: ApplyPromoCodeInput!) { - applyPromoCode(input: $input) + query VerifyPromoCode($input: VerifyPromoCodeInput!) { + verifyPromoCode(input: $input) } `, variableValues: { @@ -79,7 +79,7 @@ describe('requireAdmin directive', () => { }); expect(result.errors).toBeUndefined(); - expect(result.data?.applyPromoCode).toBe('ok'); + expect(result.data?.verifyPromoCode).toBe('ok'); }); it('should reject mutation when user is not workspace admin via input.workspaceId', async () => { @@ -88,8 +88,8 @@ describe('requireAdmin directive', () => { const result = await graphql({ schema, source: ` - query ApplyPromoCode($input: ApplyPromoCodeInput!) { - applyPromoCode(input: $input) + query VerifyPromoCode($input: VerifyPromoCodeInput!) { + verifyPromoCode(input: $input) } `, variableValues: { diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index 9489d418..d9fed062 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -145,9 +145,9 @@ function withPromoFactories( } /** - * Creates test data and mocks for applyPromoCode tests. + * Creates test data and mocks for verifyPromoCode tests. */ -function createApplyPromoCodeTestSetup(options: { +function createVerifyPromoCodeTestSetup(options: { promoCode: Record | null; grantPlan?: PlanDBScheme; }): { @@ -431,14 +431,14 @@ describe('GraphQLBillingNew', () => { }); }); - describe('applyPromoCode', () => { + describe('verifyPromoCode', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should return benefit data without side effects', async () => { const promoCodeId = new ObjectId(); - const { mockContext, workspaceId, workspaceMock } = createApplyPromoCodeTestSetup({ + const { mockContext, workspaceId, workspaceMock } = createVerifyPromoCodeTestSetup({ promoCode: { _id: promoCodeId, value: 'SAVE25', @@ -449,7 +449,7 @@ describe('GraphQLBillingNew', () => { }, }); - const result = await billingNewResolver.Mutation.applyPromoCode( + const result = await billingNewResolver.Mutation.verifyPromoCode( undefined, { input: { @@ -471,7 +471,7 @@ describe('GraphQLBillingNew', () => { it('should reject unsupported grant_plan promo', async () => { const promoCodeId = new ObjectId(); const grantPlanId = new ObjectId(); - const { mockContext, workspaceId } = createApplyPromoCodeTestSetup({ + const { mockContext, workspaceId } = createVerifyPromoCodeTestSetup({ promoCode: { _id: promoCodeId, value: 'GRANT', @@ -492,7 +492,7 @@ describe('GraphQLBillingNew', () => { }); await expect( - billingNewResolver.Mutation.applyPromoCode( + billingNewResolver.Mutation.verifyPromoCode( undefined, { input: { @@ -526,7 +526,7 @@ describe('GraphQLBillingNew', () => { }; await expect( - billingNewResolver.Mutation.applyPromoCode( + billingNewResolver.Mutation.verifyPromoCode( undefined, { input: { @@ -540,12 +540,12 @@ describe('GraphQLBillingNew', () => { }); it('should map promo validation errors to public codes', async () => { - const { mockContext, workspaceId } = createApplyPromoCodeTestSetup({ + const { mockContext, workspaceId } = createVerifyPromoCodeTestSetup({ promoCode: null, }); await expect( - billingNewResolver.Mutation.applyPromoCode( + billingNewResolver.Mutation.verifyPromoCode( undefined, { input: { diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index c2c49504..75ac5534 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -143,7 +143,7 @@ describe('PromoCodeService', () => { }); }); - describe('applyPromoCode()', () => { + describe('verifyPromoCode()', () => { it('should return benefit data for percent discount promo', async () => { const plan = createPlan({ monthlyCharge: 1000 }); const promoCode = createPromoCode({ @@ -152,7 +152,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { plan }); - const result = await service.applyPromoCode(' promo ', new ObjectId().toString(), new ObjectId().toString()); + const result = await service.verifyPromoCode(' promo ', new ObjectId().toString(), new ObjectId().toString()); expect(result).toMatchObject({ value: 'PROMO', @@ -165,7 +165,7 @@ describe('PromoCodeService', () => { it('should reject unknown promo code', async () => { const service = createService(null); - await expectPromoError(service.applyPromoCode('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.verifyPromoCode('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); it('should reject expired promo code', async () => { @@ -177,7 +177,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode); - await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); it('should reject total usage limit', async () => { @@ -189,7 +189,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { totalUses: 1 }); - await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject user usage limit', async () => { @@ -199,7 +199,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { userUsage: {} }); - await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject workspace usage limit', async () => { @@ -209,7 +209,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode, { workspaceUsage: {} }); - await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.LimitExceeded); }); it('should reject invalid benefit structure', async () => { @@ -219,7 +219,7 @@ describe('PromoCodeService', () => { }); const service = createService(promoCode); - await expectPromoError(service.applyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); }); From b0ef27ac18758f7bd99ff57e5dffa5046a3caed5 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:58:08 +0300 Subject: [PATCH 24/43] refactor(billing): streamline promo code pricing calculation logic for improved readability and maintainability --- src/utils/promoCodePricing.ts | 68 +++++++++++------------------------ 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts index e0927323..c4a2f5c1 100644 --- a/src/utils/promoCodePricing.ts +++ b/src/utils/promoCodePricing.ts @@ -82,16 +82,16 @@ export function calculatePromoCodePlanPrice( ): PromoCodePlanPrice { const originalAmount = plan.monthlyCharge; const planId = getPlanId(plan); - const isApplicable = isDiscountablePlan(plan) && isPlanApplicable(benefit, plan); - - if (!isApplicable) { - return { - planId, - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; + const result: PromoCodePlanPrice = { + planId, + isApplicable: false, + originalAmount, + finalAmount: originalAmount, + discountAmount: 0, + }; + + if (!isDiscountablePlan(plan) || !isPlanApplicable(benefit, plan)) { + return result; } switch (benefit.type) { @@ -100,51 +100,25 @@ export function calculatePromoCodePlanPrice( const discountAmount = Math.floor(originalAmount * benefit.percent / 100); const finalAmount = Math.max(originalAmount - discountAmount, minFinalPrice); - if (finalAmount >= originalAmount) { - return { - planId, - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; + if (finalAmount < originalAmount) { + result.isApplicable = true; + result.finalAmount = finalAmount; + result.discountAmount = originalAmount - finalAmount; } - return { - planId, - isApplicable: true, - originalAmount, - finalAmount, - discountAmount: originalAmount - finalAmount, - }; + return result; } case 'fixed_price': - if (benefit.amount >= originalAmount) { - return { - planId, - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; + if (benefit.amount < originalAmount) { + result.isApplicable = true; + result.finalAmount = benefit.amount; + result.discountAmount = originalAmount - benefit.amount; } - return { - planId, - isApplicable: true, - originalAmount, - finalAmount: benefit.amount, - discountAmount: originalAmount - benefit.amount, - }; + return result; default: - return { - planId, - isApplicable: false, - originalAmount, - finalAmount: originalAmount, - discountAmount: 0, - }; + return result; } } From 627f8fb433770d51b2dcc6529440937655a3b376 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:50:36 +0300 Subject: [PATCH 25/43] refactor(migrations): update index dropping syntax for promo codes and usages for consistency --- migrations/20260615140000-add-promo-code-indexes.js | 12 ++++++------ src/resolvers/billingNew.ts | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/migrations/20260615140000-add-promo-code-indexes.js b/migrations/20260615140000-add-promo-code-indexes.js index 9e289a99..c9997b36 100644 --- a/migrations/20260615140000-add-promo-code-indexes.js +++ b/migrations/20260615140000-add-promo-code-indexes.js @@ -19,11 +19,11 @@ module.exports = { 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 }); + 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/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 5d11b16c..2bbd60e4 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -216,6 +216,8 @@ export default { const checksum = await checksumService.generateChecksum(checksumData); + const loggedAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount; + /** * Send info to Telegram (non-blocking) */ @@ -223,7 +225,7 @@ export default { .sendMessage(`👀 [Billing / Compose payment] card link operation: ${isCardLinkOperation} -amount: ${+paymentAmount} RUB +amount: ${+loggedAmount} RUB last charge date: ${workspace.lastChargeDate?.toISOString()} next payment date: ${nextPaymentDate.toISOString()} workspace id: ${workspace._id.toString()} From cf31f711138ce213cd84b36a9268a21087004008 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:36:00 +0300 Subject: [PATCH 26/43] refactor(billing): enhance CloudPayments test suite with additional mocks and utility functions for improved clarity and functionality --- test/billing/cloudpayments.test.ts | 416 ++++++++++++++++++- test/integration/cases/billing/promo.test.ts | 247 +++++++++++ 2 files changed, 657 insertions(+), 6 deletions(-) create mode 100644 test/integration/cases/billing/promo.test.ts diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index fe0ca2bc..bdf34dc2 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -1,12 +1,17 @@ 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: jest.fn().mockResolvedValue(undefined), + createReceipt: (...args: unknown[]) => cloudPaymentsClientMocks.createReceipt(...args), }), getClientApi: jest.fn().mockReturnValue({ - cancelSubscription: jest.fn().mockResolvedValue(undefined), + cancelSubscription: (...args: unknown[]) => cloudPaymentsClientMocks.cancelSubscription(...args), }), })), ReceiptTypes: { @@ -17,6 +22,13 @@ jest.mock('cloudpayments', () => ({ }, })); +jest.mock('../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { + cancelPayment: jest.fn().mockResolvedValue(undefined), + }, +})); + jest.mock('../../src/mongo', () => ({ databases: { hawk: { @@ -47,11 +59,23 @@ jest.mock('@hawk.so/nodejs', () => ({ })); import { ObjectId } from 'mongodb'; -import { CardType, Currency, OperationStatus, OperationType } from '../../src/billing/types/enums'; +import { + CardType, + Currency, + Interval, + OperationStatus, + OperationType, + ReasonCode, + SubscriptionStatus, +} from '../../src/billing/types/enums'; import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; -import { CheckCodes, PayCodes } from '../../src/billing/types'; +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'; @@ -107,12 +131,69 @@ async function buildChecksumPayload(options: { }); } +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); @@ -121,7 +202,7 @@ function createWebhookContext(options: { _id: workspaceObjectId, name: 'Test Workspace', tariffPlanId: options.plan._id, - subscriptionId: null, + subscriptionId: options.subscriptionId ?? null, getMemberInfo: jest.fn().mockResolvedValue({ _id: new ObjectId(options.userId), userId: new ObjectId(options.userId), @@ -142,11 +223,13 @@ function createWebhookContext(options: { }; 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), @@ -155,7 +238,7 @@ function createWebhookContext(options: { findById: jest.fn().mockResolvedValue(user), }, businessOperationsFactory: { - create: jest.fn().mockResolvedValue(businessOperation), + create: createBusinessOperation, getBusinessOperationByTransactionId: jest.fn().mockResolvedValue(businessOperation), }, promoCodesFactory: { @@ -176,6 +259,7 @@ function createWebhookContext(options: { changePlan, createUsage, businessOperation, + createBusinessOperation, user, }; } @@ -492,6 +576,36 @@ describe('CloudPaymentsWebhooks', () => { 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 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()', () => { @@ -549,5 +663,295 @@ describe('CloudPaymentsWebhooks', () => { 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({ + userId, + 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 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(2004, '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(2005, '1', Data) }, res); + + expect(changePlan).not.toHaveBeenCalled(); + expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2005); + 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(2006, '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(2007, '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/integration/cases/billing/promo.test.ts b/test/integration/cases/billing/promo.test.ts new file mode 100644 index 00000000..08a8e25e --- /dev/null +++ b/test/integration/cases/billing/promo.test.ts @@ -0,0 +1,247 @@ +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; + +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'); + }); + + beforeEach(async () => { + const currentPlanId = (await plansCollection.insertOne({ + name: 'CurrentTestPlan', + monthlyCharge: 10, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + })).insertedId; + + const workspaceId = (await workspacesCollection.insertOne({ + name: 'PromoBillingTest', + accountId: '123', + tariffPlanId: currentPlanId, + } as WorkspaceDBScheme)).insertedId; + const workspaceResult = await workspacesCollection.findOne({ _id: workspaceId }); + + if (!workspaceResult) { + throw new Error('Failed to create workspace'); + } + + workspace = workspaceResult as WorkspaceDBScheme; + + const adminId = (await usersCollection.insertOne({ + email: 'admin@promo-billing.test', + })).insertedId; + const adminResult = await usersCollection.findOne({ _id: adminId }); + + if (!adminResult) { + throw new Error('Failed to create admin'); + } + + admin = adminResult as UserDBScheme; + + const planToChangeId = (await plansCollection.insertOne({ + name: 'PromoBasic', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 10000, + isDefault: false, + })).insertedId; + const planToChangeResult = await plansCollection.findOne({ _id: planToChangeId }); + + if (!planToChangeResult) { + throw new Error('Failed to create planToChange'); + } + + planToChange = planToChangeResult as PlanDBScheme; + + const promoCodeId = (await promoCodesCollection.insertOne({ + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: admin._id.toString(), + })).insertedId; + const promoCodeResult = await promoCodesCollection.findOne({ _id: promoCodeId }); + + if (!promoCodeResult) { + throw new Error('Failed to create promo code'); + } + + promoCode = promoCodeResult as PromoCodeDBScheme; + + const team = accountsDb.collection>(`team:${workspace._id.toString()}`); + + await team.insertOne({ + userId: admin._id, + isAdmin: true, + }); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + }); + + async function buildPromoChecksum() { + 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(), + }, + }); + } + + describe('/billing/check', () => { + it('should accept discounted amount when promo is valid', async () => { + const data: CheckRequest = { + ...mainRequest, + Amount: '750', + Currency: Currency.RUB, + Data: JSON.stringify({ + checksum: await buildPromoChecksum(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + }, + }, + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); + expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + it('should reject full plan amount when promo expects discounted charge', async () => { + const data: CheckRequest = { + ...mainRequest, + Amount: '1000', + Currency: Currency.RUB, + Data: JSON.stringify({ + checksum: await buildPromoChecksum(), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + }, + }, + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.WRONG_AMOUNT); + }); + }); + + describe('/billing/pay', () => { + let validPayRequestData: PayRequest; + + beforeEach(async () => { + await businessOperationsCollection.insertOne({ + transactionId: transactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + dtCreated: new Date(), + payload: { + workspaceId: workspace._id, + amount: 75000, + currency: Currency.RUB, + userId: admin._id, + tariffPlanId: planToChange._id, + }, + }); + + validPayRequestData = { + Amount: '750', + 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, + 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: 1000, + finalAmount: 750, + discountAmount: 250, + }); + }); + }); +}); From e0af65c57d95d69dc17dfbb9f461f7380d5c28ae Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:03:54 +0300 Subject: [PATCH 27/43] refactor(billing): improve promo code usage creation logic and enhance subscription renewal handling in CloudPayments --- src/billing/cloudpayments.ts | 29 ++++++++-------- src/models/promoCodeUsagesFactory.ts | 18 ++++++++-- src/services/promoCodeService.ts | 29 ++++++++-------- test/billing/cloudpayments.test.ts | 36 ++++++++++++++++--- test/services/promoCodeService.test.ts | 48 +++++++++++++++++++------- 5 files changed, 113 insertions(+), 47 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 20c3fbeb..dec02ba1 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -210,6 +210,17 @@ 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 && @@ -358,22 +369,12 @@ export default class CloudPaymentsWebhooks { if (data.promo && !data.isCardLinkOperation) { try { const promoCodeService = new PromoCodeService(req.context.factories); - const promoPricing = await promoCodeService.getPricingForPromoCodeId( - data.promo.id, - data.userId, - data.workspaceId, - tariffPlan - ); await promoCodeService.createUsage({ - promoCode: promoPricing.promoCode, + promoCodeId: data.promo.id, userId: data.userId, workspaceId: workspace._id, - planId: tariffPlan._id, - benefitType: promoPricing.benefitType, - originalAmount: promoPricing.originalAmount, - finalAmount: promoPricing.finalAmount, - discountAmount: promoPricing.discountAmount, + plan: tariffPlan, utm: data.promo.utm, }); } catch (error) { @@ -838,8 +839,8 @@ 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; diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts index 2e10213a..a7f35697 100644 --- a/src/models/promoCodeUsagesFactory.ts +++ b/src/models/promoCodeUsagesFactory.ts @@ -3,6 +3,18 @@ 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. */ @@ -74,14 +86,14 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory): Promise { + public async create(usageData: PromoCodeUsageCreateInput): Promise { const usage = { _id: new ObjectId(), ...usageData, }; - await this.collection.insertOne(usage); + await this.collection.insertOne(usage as PromoCodeUsageDBScheme); - return new PromoCodeUsageModel(usage); + return new PromoCodeUsageModel(usage as PromoCodeUsageDBScheme); } } diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index 386d5ce3..0cd7793c 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -322,37 +322,38 @@ export default class PromoCodeService { /** * 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. - * Usage is recorded after plan change in CloudPayments /pay. * * @param params - usage creation params * @returns created promo usage */ public async createUsage(params: { - promoCode: PromoCodeModel; + promoCodeId: string; userId: string; workspaceId: ObjectId; - planId?: ObjectId; - benefitType: PromoCodeBenefitType; - originalAmount?: number; - finalAmount?: number; - discountAmount?: number; + plan: PlanModel; utm?: PromoCodeUtm; }): Promise { - await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId); + 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: params.promoCode._id, + promoCodeId: promoPricing.promoCode._id, userId: params.userId, workspaceId: params.workspaceId, - planId: params.planId, - benefitType: params.benefitType, - originalAmount: params.originalAmount, - finalAmount: params.finalAmount, - discountAmount: params.discountAmount, + planId: params.plan._id, + benefitType: promoPricing.benefitType, + originalAmount: promoPricing.originalAmount, + finalAmount: promoPricing.finalAmount, + discountAmount: promoPricing.discountAmount, appliedAt: new Date(), ...(utm ? { utm } : {}), }); diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index bdf34dc2..91283882 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -592,6 +592,36 @@ describe('CloudPaymentsWebhooks', () => { 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 when promo is not applied', async () => { const webhooks = new CloudPaymentsWebhooks() as any; const workspaceId = new ObjectId().toString(); @@ -689,11 +719,9 @@ describe('CloudPaymentsWebhooks', () => { expect(changePlan).toHaveBeenCalledWith(plan._id); expect(createUsage).toHaveBeenCalledWith(expect.objectContaining({ + promoCodeId: promoCode._id.toString(), userId, - benefitType: 'percent_discount', - originalAmount: 1000, - finalAmount: 750, - discountAmount: 250, + plan: expect.objectContaining({ _id: plan._id }), })); expect(publish).toHaveBeenCalled(); expect(sendNotification).toHaveBeenCalledWith( diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 75ac5534..6e7c2764 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -269,36 +269,58 @@ describe('PromoCodeService', () => { }); 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({ - promoCode, + promoCodeId: promoCode._id.toString(), userId: new ObjectId().toString(), workspaceId: new ObjectId(), - planId: new ObjectId(), - benefitType: 'fixed_price', - originalAmount: 1000, - finalAmount: 100, - discountAmount: 900, + 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, @@ -307,22 +329,24 @@ describe('PromoCodeService', () => { .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 = { - promoCode, + promoCodeId: promoCode._id.toString(), userId: new ObjectId().toString(), workspaceId: new ObjectId(), - planId: new ObjectId(), - benefitType: 'fixed_price' as const, - originalAmount: 1000, - finalAmount: 100, - discountAmount: 900, + plan, }; await service.createUsage(usageParams); From 56934675ee4eccacd1b8ac1110581ddd6a80a508 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:08:37 +0300 Subject: [PATCH 28/43] refactor(billing): remove unused promo code fields and simplify payment amount calculation logic --- src/resolvers/billingNew.ts | 22 +--------------------- src/typeDefs/billing.ts | 15 --------------- test/resolvers/billingNew.test.ts | 28 ++++------------------------ 3 files changed, 5 insertions(+), 60 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 2bbd60e4..bf91509d 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -177,11 +177,8 @@ export default { paymentAmount = pricing.finalAmount; paymentPromoChecksum = buildPaymentPromoData(pricing.promoCode._id.toString(), promoUtm); composePaymentPromo = { - id: pricing.promoCode._id.toString(), - benefitType: pricing.benefitType, originalAmount: pricing.originalAmount, finalAmount: pricing.finalAmount, - discountAmount: pricing.discountAmount, }; } catch (error) { throwPromoCodeGraphQLError(error); @@ -375,23 +372,6 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Wrong checksum data'); } - let planPaymentAmount = plan.monthlyCharge; - - if (paymentData.promo?.id) { - try { - const pricing = await new PromoCodeService(factories).getPricingForPromoCodeId( - paymentData.promo.id, - user.id, - paymentData.workspaceId, - plan - ); - - planPaymentAmount = pricing.finalAmount; - } catch (error) { - throwPromoCodeGraphQLError(error); - } - } - const token = fullUserInfo.bankCards?.find(card => card.id === args.input.cardId)?.token; if (!token) { @@ -425,7 +405,7 @@ debug: ${Boolean(workspace.isDebug)}` } } - let amount = planPaymentAmount; + let amount = plan.monthlyCharge; const isPaymentForCurrentTariffPlan = workspace.tariffPlanId.toString() === plan._id.toString(); diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 11b8c336..516f67b7 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -311,16 +311,6 @@ type VerifyPromoCodeResponse { Promo data returned with composePayment """ type ComposePaymentPromo { - """ - Applied promo code id - """ - id: ID! - - """ - Promo benefit type - """ - benefitType: PromoCodeBenefitType! - """ Plan price before promo """ @@ -330,11 +320,6 @@ type ComposePaymentPromo { Plan price after promo """ finalAmount: Int! - - """ - Actual discount amount in money - """ - discountAmount: Int! } """ diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index d9fed062..047bc28a 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -356,11 +356,8 @@ describe('GraphQLBillingNew', () => { expect(result.plan.monthlyCharge).toBe(1000); expect(result.chargeAmount).toBe(750); expect(result.promo).toMatchObject({ - id: promoCodeId.toString(), - benefitType: 'percent_discount', originalAmount: 1000, finalAmount: 750, - discountAmount: 250, }); const checksumData = checksumService.parseAndVerifyChecksum(result.checksum); @@ -566,8 +563,7 @@ describe('GraphQLBillingNew', () => { jest.clearAllMocks(); }); - it('should charge discounted amount and set full recurrent amount from checksum promo id', async () => { - const promoCodeId = new ObjectId(); + it('should charge full plan amount for recurrent payment', async () => { const userId = new ObjectId().toString(); const workspaceId = new ObjectId().toString(); const cardId = 'card-1'; @@ -587,9 +583,6 @@ describe('GraphQLBillingNew', () => { tariffPlanId: newPlanId.toString(), shouldSaveCard: false, nextPaymentDate: new Date().toISOString(), - promo: { - id: promoCodeId.toString(), - }, }); const dueDate = new Date(); dueDate.setMonth(dueDate.getMonth() + 1); @@ -626,21 +619,8 @@ describe('GraphQLBillingNew', () => { getBusinessOperationByTransactionId: jest.fn().mockResolvedValue({ _id: new ObjectId() }), } as any, releasesFactory: {} as any, - promoCodesFactory: { - findOne: jest.fn().mockResolvedValue({ - _id: promoCodeId, - value: 'SAVE25', - benefit: { - type: 'percent_discount', - percent: 25, - }, - }), - } as any, - promoCodeUsagesFactory: { - countByPromoCodeId: jest.fn().mockResolvedValue(0), - findByPromoCodeAndUser: jest.fn().mockResolvedValue(null), - findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null), - } as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; @@ -664,7 +644,7 @@ describe('GraphQLBillingNew', () => { expect(cloudPaymentsApi.payByToken).toHaveBeenCalledWith( expect.objectContaining({ - Amount: 750, + Amount: 1000, JsonData: expect.objectContaining({ cloudPayments: expect.objectContaining({ recurrent: expect.objectContaining({ From 158f2efd4cb8b19b09ae93bfda73db30aff60e82 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:12:27 +0300 Subject: [PATCH 29/43] refactor(billing): enhance promo billing test suite with improved fixture setup and utility functions for clarity --- test/integration/cases/billing/promo.test.ts | 341 ++++++++++--------- 1 file changed, 184 insertions(+), 157 deletions(-) diff --git a/test/integration/cases/billing/promo.test.ts b/test/integration/cases/billing/promo.test.ts index 08a8e25e..f92c5c36 100644 --- a/test/integration/cases/billing/promo.test.ts +++ b/test/integration/cases/billing/promo.test.ts @@ -17,7 +17,13 @@ import checksumService from '../../../../src/utils/checksumService'; import { mainRequest, transactionId } from '../../billingMocks'; import type { Global } from '@jest/types'; - declare var global: Global.Global; +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; @@ -44,71 +50,87 @@ describe('Promo billing webhooks', () => { promoCodeUsagesCollection = accountsDb.collection>('promoCodeUsages'); }); - beforeEach(async () => { - const currentPlanId = (await plansCollection.insertOne({ - name: 'CurrentTestPlan', - monthlyCharge: 10, - monthlyChargeCurrency: 'RUB', - eventsLimit: 1000, - isDefault: false, - })).insertedId; - - const workspaceId = (await workspacesCollection.insertOne({ - name: 'PromoBillingTest', - accountId: '123', - tariffPlanId: currentPlanId, - } as WorkspaceDBScheme)).insertedId; - const workspaceResult = await workspacesCollection.findOne({ _id: workspaceId }); - - if (!workspaceResult) { - throw new Error('Failed to create workspace'); - } - - workspace = workspaceResult as WorkspaceDBScheme; - - const adminId = (await usersCollection.insertOne({ - email: 'admin@promo-billing.test', - })).insertedId; - const adminResult = await usersCollection.findOne({ _id: adminId }); + afterEach(async () => { + await accountsDb.dropDatabase(); + }); - if (!adminResult) { - throw new Error('Failed to create admin'); + /** + * 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 as T)).insertedId; + const result = await collection.findOne({ _id: insertedId } as Partial); + + if (!result) { + throw new Error(errorMessage); } - admin = adminResult as UserDBScheme; + return result as R; + } - const planToChangeId = (await plansCollection.insertOne({ - name: 'PromoBasic', - monthlyCharge: 1000, + /** + * 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: 10000, + eventsLimit: 1000, isDefault: false, })).insertedId; - const planToChangeResult = await plansCollection.findOne({ _id: planToChangeId }); - - if (!planToChangeResult) { - throw new Error('Failed to create planToChange'); - } - planToChange = planToChangeResult as PlanDBScheme; - - const promoCodeId = (await promoCodesCollection.insertOne({ - value: 'SAVE25', - benefit: { - type: 'percent_discount', - percent: 25, + workspace = await insertAndLoad( + workspacesCollection, + { + name: 'PromoBillingTest', + accountId: '123', + tariffPlanId: currentPlanId, + } as WorkspaceDBScheme, + '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, }, - createdAt: new Date(), - updatedAt: new Date(), - createdBy: admin._id.toString(), - })).insertedId; - const promoCodeResult = await promoCodesCollection.findOne({ _id: promoCodeId }); - - if (!promoCodeResult) { - throw new Error('Failed to create promo code'); - } - - promoCode = promoCodeResult as PromoCodeDBScheme; + '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()}`); @@ -116,13 +138,13 @@ describe('Promo billing webhooks', () => { userId: admin._id, isAdmin: true, }); - }); - - afterEach(async () => { - await accountsDb.dropDatabase(); - }); + } - async function buildPromoChecksum() { + /** + * 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(), @@ -135,112 +157,117 @@ describe('Promo billing webhooks', () => { }); } - describe('/billing/check', () => { - it('should accept discounted amount when promo is valid', async () => { - const data: CheckRequest = { - ...mainRequest, - Amount: '750', - Currency: Currency.RUB, - Data: JSON.stringify({ - checksum: await buildPromoChecksum(), - cloudPayments: { - recurrent: { - interval: 'Month', - period: 1, - amount: 1000, - }, + /** + * 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, }, - }), - }; - - const apiResponse = await apiInstance.post('/billing/check', data); - const createdBusinessOperation = await businessOperationsCollection.findOne({ - transactionId: transactionId.toString(), - }); + }, + }), + }; + } - expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); - expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); - }); + beforeEach(async () => { + await seedPromoBillingFixtures(); + }); - it('should reject full plan amount when promo expects discounted charge', async () => { - const data: CheckRequest = { - ...mainRequest, - Amount: '1000', - Currency: Currency.RUB, - Data: JSON.stringify({ - checksum: await buildPromoChecksum(), - cloudPayments: { - recurrent: { - interval: 'Month', - period: 1, - amount: 1000, - }, - }, - }), - }; + 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); + }); - const apiResponse = await apiInstance.post('/billing/check', data); + 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); + expect(apiResponse.data.code).toBe(CheckCodes.WRONG_AMOUNT); + }); }); }); describe('/billing/pay', () => { - let validPayRequestData: PayRequest; - - beforeEach(async () => { - await businessOperationsCollection.insertOne({ - transactionId: transactionId.toString(), - type: BusinessOperationType.WorkspacePlanPurchase, - status: BusinessOperationStatus.Pending, - dtCreated: new Date(), - payload: { - workspaceId: workspace._id, - amount: 75000, - currency: Currency.RUB, - userId: admin._id, - tariffPlanId: planToChange._id, - }, + 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, + TransactionId: transactionId, + Token: '123123', + IssuerBankCountry: 'US', + Data: JSON.stringify({ + checksum: await buildPromoChecksum(), + }), + }; }); - validPayRequestData = { - Amount: '750', - 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, - 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 }); - 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: 1000, - finalAmount: 750, - discountAmount: 250, + 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, + }); }); }); }); From 02d13a2a3be1df36ba11655e57077a245d51c656 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:56:18 +0300 Subject: [PATCH 30/43] fix (tests) --- src/resolvers/billingNew.ts | 3 +++ test/billing/cloudpayments.test.ts | 9 +++++++-- test/services/promoCodeService.test.ts | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index bf91509d..881bc5a5 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -177,8 +177,11 @@ export default { paymentAmount = pricing.finalAmount; paymentPromoChecksum = buildPaymentPromoData(pricing.promoCode._id.toString(), promoUtm); composePaymentPromo = { + id: pricing.promoCode._id.toString(), + benefitType: pricing.benefitType, originalAmount: pricing.originalAmount, finalAmount: pricing.finalAmount, + discountAmount: pricing.discountAmount, }; } catch (error) { throwPromoCodeGraphQLError(error); diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index 91283882..9eacd6b1 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -719,9 +719,14 @@ describe('CloudPaymentsWebhooks', () => { expect(changePlan).toHaveBeenCalledWith(plan._id); expect(createUsage).toHaveBeenCalledWith(expect.objectContaining({ - promoCodeId: promoCode._id.toString(), + promoCodeId: promoCode._id, userId, - plan: expect.objectContaining({ _id: plan._id }), + workspaceId: expect.any(ObjectId), + planId: plan._id, + benefitType: 'percent_discount', + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, })); expect(publish).toHaveBeenCalled(); expect(sendNotification).toHaveBeenCalledWith( diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 6e7c2764..24b4b58d 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -43,6 +43,7 @@ function createService(promoCode: any, options: { return new PromoCodeService({ promoCodesFactory: { findByValue: jest.fn().mockResolvedValue(promoCode), + findOne: jest.fn().mockResolvedValue(promoCode), }, promoCodeUsagesFactory: { countByPromoCodeId: jest.fn().mockResolvedValue(options.totalUses ?? 0), From 910ac27eb36c4f3ffe0152a2f70ca9fc8bab0387 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:55:21 +0300 Subject: [PATCH 31/43] refactor(billing): enhance CloudPayments webhook handling and improve documentation for payment processing and subscription renewals --- src/billing/cloudpayments.ts | 28 +++++++++++-- src/billing/types/paymentData.ts | 6 ++- test/billing/cloudpayments.test.ts | 43 +++++++++++++++++--- test/integration/cases/billing/promo.test.ts | 15 +++---- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index dec02ba1..ba9dd777 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -102,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 */ @@ -642,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 */ @@ -830,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 */ diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index e9743632..4960e68a 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -20,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; } @@ -38,8 +39,9 @@ interface CloudPaymentsSettings { } /** - * Promo reference attached to payment request. + * 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 { /** diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index 9eacd6b1..df619921 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -166,7 +166,7 @@ function createCheckBody(transactionId: number, amount: string, data: string) { }; } -function createPayBody(transactionId: number, amount: string, data: string, overrides: Record = {}) { +function createPayBody(transactionId: number, amount: string, data?: string, overrides: Record = {}) { return { TransactionId: transactionId, Amount: amount, @@ -758,6 +758,37 @@ describe('CloudPaymentsWebhooks', () => { 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(); @@ -775,7 +806,7 @@ describe('CloudPaymentsWebhooks', () => { await webhooks.pay({ context, - body: createPayBody(2004, '1000', Data, { SubscriptionId: 'new-subscription' }), + body: createPayBody(2005, '1000', Data, { SubscriptionId: 'new-subscription' }), }, res); expect(cloudPaymentsClientMocks.cancelSubscription).toHaveBeenCalledWith({ Id: 'old-subscription' }); @@ -807,10 +838,10 @@ describe('CloudPaymentsWebhooks', () => { }, }); - await webhooks.pay({ context, body: createPayBody(2005, '1', Data) }, res); + await webhooks.pay({ context, body: createPayBody(2006, '1', Data) }, res); expect(changePlan).not.toHaveBeenCalled(); - expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2005); + expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2006); expect(createBusinessOperation).toHaveBeenCalledWith(expect.objectContaining({ type: BusinessOperationType.CardLinkRefund, status: BusinessOperationStatus.Confirmed, @@ -831,7 +862,7 @@ describe('CloudPaymentsWebhooks', () => { (publish as jest.Mock).mockRejectedValueOnce(new Error('rabbit down')); - await webhooks.pay({ context, body: createPayBody(2006, '1000', Data) }, res); + await webhooks.pay({ context, body: createPayBody(2007, '1000', Data) }, res); expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); }); @@ -848,7 +879,7 @@ describe('CloudPaymentsWebhooks', () => { (sendNotification as jest.Mock).mockRejectedValueOnce(new Error('notify failed')); - await webhooks.pay({ context, body: createPayBody(2007, '1000', Data) }, res); + await webhooks.pay({ context, body: createPayBody(2008, '1000', Data) }, res); expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); }); diff --git a/test/integration/cases/billing/promo.test.ts b/test/integration/cases/billing/promo.test.ts index f92c5c36..5ba03544 100644 --- a/test/integration/cases/billing/promo.test.ts +++ b/test/integration/cases/billing/promo.test.ts @@ -58,19 +58,19 @@ describe('Promo billing webhooks', () => { * 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, + async function insertAndLoad( + collection: Collection, + document: Omit, errorMessage: string ): Promise { - const insertedId = (await collection.insertOne(document as T)).insertedId; - const result = await collection.findOne({ _id: insertedId } as Partial); + const insertedId = (await collection.insertOne(document)).insertedId; + const result = await collection.findOne({ _id: insertedId }); if (!result) { throw new Error(errorMessage); } - return result as R; + return result as unknown as R; } /** @@ -95,7 +95,7 @@ describe('Promo billing webhooks', () => { name: 'PromoBillingTest', accountId: '123', tariffPlanId: currentPlanId, - } as WorkspaceDBScheme, + } as Omit, 'Failed to create workspace' ); @@ -241,6 +241,7 @@ describe('Promo billing webhooks', () => { OperationType: OperationType.PAYMENT, Status: OperationStatus.COMPLETED, TestMode: false, + TotalFee: 0, TransactionId: transactionId, Token: '123123', IssuerBankCountry: 'US', From 2e5ad239e6d05b254a458278ef7e75e0e04d3e8b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:30:29 +0300 Subject: [PATCH 32/43] test(billing): add tests for subscription renewal data retrieval and card-link validation in CloudPayments webhooks --- test/billing/cloudpayments.test.ts | 96 ++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index df619921..9786178b 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -329,9 +329,75 @@ describe('CloudPaymentsWebhooks', () => { isCardLinkOperation: false, }); }); + + 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 reject wrong amount when promo id is in checksum', async () => { const webhooks = new CloudPaymentsWebhooks() as any; const workspaceId = new ObjectId().toString(); @@ -622,6 +688,36 @@ describe('CloudPaymentsWebhooks', () => { 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(); From 36af36d7ab6777cd733ba7671646e073c7be84a3 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:45:13 +0300 Subject: [PATCH 33/43] refactor(billing): remove unused promo code fields and update charge amount calculation for clarity --- src/resolvers/billingNew.ts | 17 +++++------------ src/typeDefs/billing.ts | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 881bc5a5..2cd83089 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -6,7 +6,6 @@ import { BusinessOperationPayloadType, PayloadOfDepositByUser, PayloadOfWorkspacePlanPurchase, - PromoCodeBenefitType, Utm } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; @@ -119,11 +118,8 @@ export default { nextPaymentDate: Date; cloudPaymentsPublicId: string; promo?: { - id: string; - benefitType: PromoCodeBenefitType; originalAmount: number; finalAmount: number; - discountAmount: number; }; }> { const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; @@ -165,7 +161,7 @@ export default { isCardLinkOperation = true; } - let paymentAmount = plan.monthlyCharge; + let tariffChargeAmount = plan.monthlyCharge; let paymentPromoChecksum: PaymentPromoData | undefined; let composePaymentPromo; @@ -174,14 +170,11 @@ export default { const promoCodeService = new PromoCodeService(factories); const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); - paymentAmount = pricing.finalAmount; + tariffChargeAmount = pricing.finalAmount; paymentPromoChecksum = buildPaymentPromoData(pricing.promoCode._id.toString(), promoUtm); composePaymentPromo = { - id: pricing.promoCode._id.toString(), - benefitType: pricing.benefitType, originalAmount: pricing.originalAmount, finalAmount: pricing.finalAmount, - discountAmount: pricing.discountAmount, }; } catch (error) { throwPromoCodeGraphQLError(error); @@ -216,7 +209,7 @@ export default { const checksum = await checksumService.generateChecksum(checksumData); - const loggedAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount; + const chargeAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : tariffChargeAmount; /** * Send info to Telegram (non-blocking) @@ -225,7 +218,7 @@ export default { .sendMessage(`👀 [Billing / Compose payment] card link operation: ${isCardLinkOperation} -amount: ${+loggedAmount} RUB +amount: ${chargeAmount} RUB last charge date: ${workspace.lastChargeDate?.toISOString()} next payment date: ${nextPaymentDate.toISOString()} workspace id: ${workspace._id.toString()} @@ -240,7 +233,7 @@ debug: ${Boolean(workspace.isDebug)}` name: plan.name, monthlyCharge: plan.monthlyCharge, }, - chargeAmount: isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount, + chargeAmount, isCardLinkOperation, currency: 'RUB', checksum, diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 516f67b7..c89effed 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -337,7 +337,7 @@ type ComposePaymentResponse { plan: ComposePaymentPlanInfo! """ - Amount to charge for this payment (may differ from plan.monthlyCharge when promo is applied) + Amount to charge for this payment (card validation amount, promo price, or full plan price) """ chargeAmount: Int! From 9c2e51efd4d593b5cdad81fefa209c0080aa14ce Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:46:13 +0300 Subject: [PATCH 34/43] test(billing): add tests for card-link checksum handling and amount validation in CloudPayments webhooks --- test/billing/cloudpayments.test.ts | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index 9786178b..552746a0 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -330,6 +330,50 @@ describe('CloudPaymentsWebhooks', () => { }); }); + 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(); @@ -398,6 +442,40 @@ describe('CloudPaymentsWebhooks', () => { 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(); From b08177ce3e6d945878df11257583e9bb1ce90124 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:14:24 +0300 Subject: [PATCH 35/43] refactor(billing): remove validation for discounted recurrent amounts and clarify subscription renewal pricing in CloudPayments --- src/billing/cloudpayments.ts | 26 ---------------- src/resolvers/billingNew.ts | 5 +++ test/billing/cloudpayments.test.ts | 49 ------------------------------ 3 files changed, 5 insertions(+), 75 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index ba9dd777..b8a56c57 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -216,32 +216,6 @@ 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 */ diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 2cd83089..ade8cce7 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -388,6 +388,11 @@ debug: ${Boolean(workspace.isDebug)}` recurrent: { interval, period: 1, + /** + * Subscription renewals must always bill the full plan price. + * Amount below may be promo-discounted or reduced for card validation, + * but CloudPayments uses recurrent.amount for future automatic charges. + */ amount: plan.monthlyCharge, }, }; diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts index 552746a0..892cb910 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -575,55 +575,6 @@ describe('CloudPaymentsWebhooks', () => { 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(); From b597b5cb4066d3deca973a5a82c63817d67e83d5 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:18:18 +0000 Subject: [PATCH 36/43] Bump version up to 1.5.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7625c89e..ff6375cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.5", + "version": "1.5.6", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 82638b6f34787865397e63309976092ce77d83ec Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:59:46 +0000 Subject: [PATCH 37/43] Bump version up to 1.5.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff6375cd..027874f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.6", + "version": "1.5.7", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 8d026d75a2a4d220bee15deec6aa875a87d55312 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:12:01 +0300 Subject: [PATCH 38/43] docs(promoCodePricing): add JSDoc comments for clarity on pricing functions --- src/utils/promoCodePricing.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts index c4a2f5c1..ae6abb2b 100644 --- a/src/utils/promoCodePricing.ts +++ b/src/utils/promoCodePricing.ts @@ -3,6 +3,9 @@ import type { PercentDiscountPromoCodeBenefit } from '@hawk.so/types'; +/** + * Default floor for percent-discount final price when benefit has no minFinalPrice + */ const DEFAULT_MIN_FINAL_PRICE = 1; /** @@ -50,10 +53,22 @@ function getPlanId(plan: PromoCodePricingPlan): string { throw new Error('Plan id is required for promo pricing'); } +/** + * Whether plan can be purchased (hidden plans are not sellable) + * + * @param plan - pricing plan + */ function isPlanAvailableForPurchase(plan: PromoCodePricingPlan): boolean { return plan.isHidden !== true; } +/** + * Whether promo benefit applies to the given plan. + * Empty/missing applicablePlanIds means all plans. + * + * @param benefit - promo benefit + * @param plan - pricing plan + */ function isPlanApplicable(benefit: PromoCodePricingBenefit, plan: PromoCodePricingPlan): boolean { if (!benefit.applicablePlanIds || benefit.applicablePlanIds.length === 0) { return true; @@ -64,6 +79,11 @@ function isPlanApplicable(benefit: PromoCodePricingBenefit, plan: PromoCodePrici return benefit.applicablePlanIds.some((applicablePlanId): boolean => applicablePlanId.toString() === planId); } +/** + * Whether plan can receive a promo discount (paid + available for purchase) + * + * @param plan - pricing plan + */ function isDiscountablePlan(plan: PromoCodePricingPlan): boolean { return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); } @@ -73,6 +93,8 @@ function isDiscountablePlan(plan: PromoCodePricingPlan): boolean { * * Keep in sync with garage/src/utils/promoCodePricing.ts * + * Unknown benefit types return the original price with isApplicable=false. + * * @param benefit - promo benefit * @param plan - selected plan */ From b967ac072f0e2b03c112ea8755cf8782932eeeb8 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:02:29 +0000 Subject: [PATCH 39/43] Bump version up to 1.5.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8af97bb7..80c6a60d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.8", + "version": "1.5.9", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From d2d216df5cb47b512d76c92002796fa12f9b555f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:06:46 +0300 Subject: [PATCH 40/43] Bump version to 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 80c6a60d..2b690dc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.9", + "version": "1.6.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From ca8ff4676ae9f978415def9d67906a3e0896b64f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:57:39 +0300 Subject: [PATCH 41/43] feat(promo): enhance promo code validation and pricing logic --- src/services/promoCodeService.ts | 9 +++++++ src/typeDefs/billing.ts | 4 ++- src/utils/promoCodePricing.ts | 15 ++++++++++- test/directives/requireAdmin.test.ts | 4 +-- test/resolvers/billingNew.test.ts | 36 ++++++++++++++------------ test/services/promoCodeService.test.ts | 27 +++++++++++++++++++ 6 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index 0cd7793c..4db4c175 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -161,6 +161,15 @@ function validateBenefitStructure(benefit: PromoCodeModel['benefit']): void { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Percent discount is invalid'); } + if ( + benefit.minFinalPrice !== undefined && + (typeof benefit.minFinalPrice !== 'number' || + !Number.isFinite(benefit.minFinalPrice) || + benefit.minFinalPrice < DEFAULT_MIN_FINAL_PRICE) + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Minimum final price is invalid'); + } + return; case 'fixed_price': diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index c89effed..d45bb817 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -337,7 +337,9 @@ type ComposePaymentResponse { plan: ComposePaymentPlanInfo! """ - Amount to charge for this payment (card validation amount, promo price, or full plan price) + Amount CloudPayments should charge now. + 1 RUB for card linking, promo.finalAmount when a promo applies, otherwise plan.monthlyCharge. + Not the same as promo: promo is an optional pricing breakdown and is absent on card-link payments. """ chargeAmount: Int! diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts index ae6abb2b..8f35dc10 100644 --- a/src/utils/promoCodePricing.ts +++ b/src/utils/promoCodePricing.ts @@ -88,6 +88,19 @@ function isDiscountablePlan(plan: PromoCodePricingPlan): boolean { return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); } +/** + * Floor for percent-discount final price. Invalid or missing values fall back to DEFAULT_MIN_FINAL_PRICE. + * + * @param minFinalPrice - optional floor from promo benefit + */ +function resolveMinFinalPrice(minFinalPrice?: number): number { + if (typeof minFinalPrice !== 'number' || !Number.isFinite(minFinalPrice)) { + return DEFAULT_MIN_FINAL_PRICE; + } + + return Math.max(minFinalPrice, DEFAULT_MIN_FINAL_PRICE); +} + /** * Calculates discounted price for one plan. * @@ -118,7 +131,7 @@ export function calculatePromoCodePlanPrice( switch (benefit.type) { case 'percent_discount': { - const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; + const minFinalPrice = resolveMinFinalPrice(benefit.minFinalPrice); const discountAmount = Math.floor(originalAmount * benefit.percent / 100); const finalAmount = Math.max(originalAmount - discountAmount, minFinalPrice); diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts index c59e7158..86e24c5d 100644 --- a/test/directives/requireAdmin.test.ts +++ b/test/directives/requireAdmin.test.ts @@ -59,7 +59,7 @@ function createContext(options: { isAdmin: boolean }) { } describe('requireAdmin directive', () => { - it('should allow mutation when user is workspace admin via input.workspaceId', async () => { + it('should allow query when user is workspace admin via input.workspaceId', async () => { const context = createContext({ isAdmin: true }); const result = await graphql({ @@ -82,7 +82,7 @@ describe('requireAdmin directive', () => { expect(result.data?.verifyPromoCode).toBe('ok'); }); - it('should reject mutation when user is not workspace admin via input.workspaceId', async () => { + it('should reject query when user is not workspace admin via input.workspaceId', async () => { const context = createContext({ isAdmin: false }); const result = await graphql({ diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index 047bc28a..815f0af3 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -465,26 +465,28 @@ describe('GraphQLBillingNew', () => { expect(workspaceMock.changePlan).not.toHaveBeenCalled(); }); - it('should reject unsupported grant_plan promo', async () => { + it.each([ + [ + 'grant_plan', + { + type: 'grant_plan', + planId: new ObjectId(), + }, + ], + [ + 'amount_discount', + { + type: 'amount_discount', + amount: 100, + }, + ], + ])('should reject unsupported %s promo', async (_type, benefit) => { 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, + value: 'UNSUPPORTED', + benefit, }, }); @@ -494,7 +496,7 @@ describe('GraphQLBillingNew', () => { { input: { workspaceId, - value: 'grant', + value: 'unsupported', }, }, mockContext diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts index 24b4b58d..3fd97b19 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -88,6 +88,22 @@ describe('PromoCodeService', () => { }); }); + it('should clamp non-positive minFinalPrice to default floor', () => { + const plan = createPlan({ monthlyCharge: 1000 }); + const price = calculatePromoCodePlanPrice({ + type: 'percent_discount', + percent: 100, + minFinalPrice: 0, + } as any, plan); + + expect(price).toMatchObject({ + isApplicable: true, + originalAmount: 1000, + finalAmount: 1, + discountAmount: 999, + }); + }); + it('should apply fixed price promo', () => { const plan = createPlan({ monthlyCharge: 1000 }); const price = calculatePromoCodePlanPrice({ @@ -222,6 +238,17 @@ describe('PromoCodeService', () => { await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); }); + + it.each([0, -1, Number.NaN])('should reject percent discount with invalid minFinalPrice %p', async (minFinalPrice) => { + const promoCode = createPromoCode({ + type: 'percent_discount', + percent: 50, + minFinalPrice, + }); + const service = createService(promoCode); + + await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + }); }); describe('getPricingForPlan()', () => { From 36d926bc4bbdfd690c7a5405bbb958064e320a5c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:42:21 +0300 Subject: [PATCH 42/43] refactor(billing): simplify promo code payment flow Co-authored-by: Cursor --- .../20260615140000-add-promo-code-indexes.js | 43 +- package.json | 4 +- src/billing/cloudpayments.ts | 293 ++-- src/billing/types/paymentData.ts | 63 +- src/billing/types/response.ts | 5 + src/directives/requireAdmin.ts | 8 - src/index.ts | 17 +- src/models/promoCode.ts | 66 - src/models/promoCodeUsage.ts | 81 - src/models/promoCodeUsagesFactory.ts | 99 -- src/models/promoCodesFactory.ts | 33 - src/models/user.ts | 3 +- src/models/usersFactory.ts | 3 +- src/resolvers/billingNew.ts | 154 +- src/resolvers/user.ts | 4 +- src/services/promoCodeService.ts | 671 ++++---- src/typeDefs/billing.ts | 95 +- src/types/graphql.ts | 12 - src/utils/checksumService.ts | 95 +- src/utils/promoCodePricing.ts | 159 -- src/utils/utm/utm.ts | 35 +- test/billing/cloudpayments.test.ts | 1370 +++++------------ test/directives/requireAdmin.test.ts | 106 -- test/integration/cases/billing/check.test.ts | 12 +- test/integration/cases/billing/promo.test.ts | 275 ---- test/integrations/github-routes.test.ts | 42 - test/resolvers/billingNew.test.ts | 449 ++---- test/resolvers/project.test.ts | 4 - test/services/promoCodeService.test.ts | 625 ++++---- test/sso/saml/controller.test.ts | 2 - test/utils/utm.test.ts | 17 +- yarn.lock | 8 +- 32 files changed, 1362 insertions(+), 3491 deletions(-) delete mode 100644 src/models/promoCode.ts delete mode 100644 src/models/promoCodeUsage.ts delete mode 100644 src/models/promoCodeUsagesFactory.ts delete mode 100644 src/models/promoCodesFactory.ts delete mode 100644 src/utils/promoCodePricing.ts delete mode 100644 test/directives/requireAdmin.test.ts delete mode 100644 test/integration/cases/billing/promo.test.ts diff --git a/migrations/20260615140000-add-promo-code-indexes.js b/migrations/20260615140000-add-promo-code-indexes.js index c9997b36..6dbcfda0 100644 --- a/migrations/20260615140000-add-promo-code-indexes.js +++ b/migrations/20260615140000-add-promo-code-indexes.js @@ -1,29 +1,40 @@ /** - * @file Migration to add indexes for promoCodes and promoCodeUsages collections + * @file Indexes required by promo code validation and reservation flow */ module.exports = { async up(db) { const promoCodes = db.collection('promoCodes'); - const promoCodeUsages = db.collection('promoCodeUsages'); + const usages = 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 }); + await usages.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true }); + await usages.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true }); + await usages.createIndex( + { transactionId: 1 }, + { + unique: true, + partialFilterExpression: { transactionId: { $type: 'string' } }, + } + ); + await usages.createIndex( + { promoCodeId: 1, ordinal: 1 }, + { + unique: true, + partialFilterExpression: { ordinal: { $type: 'number' } }, + } + ); + await usages.createIndex({ reservationExpiresAt: 1 }, { expireAfterSeconds: 0 }); }, async down(db) { - const promoCodes = db.collection('promoCodes'); - const promoCodeUsages = db.collection('promoCodeUsages'); + await db.collection('promoCodes').dropIndex('value_1'); + + const usages = 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'); + await usages.dropIndex('promoCodeId_1_userId_1'); + await usages.dropIndex('promoCodeId_1_workspaceId_1'); + await usages.dropIndex('transactionId_1'); + await usages.dropIndex('promoCodeId_1_ordinal_1'); + await usages.dropIndex('reservationExpiresAt_1'); }, }; diff --git a/package.json b/package.json index 3bca1b62..db9cff2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.0", + "version": "1.5.13", "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.6.3", + "@hawk.so/types": "^0.5.9", "@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 c4d4e591..c81dcfa0 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -22,8 +22,7 @@ import { BusinessOperationType, ConfirmedMemberDBScheme, PayloadOfWorkspacePlanPurchase, - PlanDBScheme, - PlanProlongationPayload + PlanDBScheme } from '@hawk.so/types'; import WorkspaceModel from '../models/workspace'; import HawkCatcher from '@hawk.so/nodejs'; @@ -42,9 +41,10 @@ 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'; +import { PromoCodeContext } from '../services/promoCodeService'; const PENNY_MULTIPLIER = 100; +const AMOUNT_FOR_CARD_VALIDATION = 1; /** * Class for describing the logic of payment routes @@ -102,15 +102,9 @@ export default class CloudPaymentsWebhooks { } /** - * Route to confirm that CloudPayments may process the payment. + * Route to confirm the correctness of a user's 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 */ @@ -131,33 +125,22 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ - if (data.isCardLinkOperation) { - if (!data.userId || !data.workspaceId) { - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the card linking request', req.body); - - return; - } - } else { - if (!data.userId || !data.workspaceId || !data.tariffPlanId) { - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); + if (!data.userId || !data.workspaceId || !data.tariffPlanId) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); - return; - } + return; } let workspace: WorkspaceModel; let member: ConfirmedMemberDBScheme; let plan: PlanModel; - let planId: string; const { workspaceId, userId, tariffPlanId } = data; try { workspace = await this.getWorkspace(req, workspaceId); member = await this.getMember(userId, workspace); - planId = data.isCardLinkOperation ? workspace.tariffPlanId.toString() : tariffPlanId; - plan = await this.getPlan(req, planId); + plan = await this.getPlan(req, tariffPlanId); } catch (e) { const error = e as Error; @@ -166,81 +149,124 @@ export default class CloudPaymentsWebhooks { return; } - const recurrentPaymentSettings = data.cloudPayments?.recurrent; - let promoPricing; + const expectedAmount = data.chargeAmount ?? plan.monthlyCharge; + const hasValidSignedAmount = + Number.isFinite(expectedAmount) && + expectedAmount > 0 && + (!data.isCardLinkOperation || expectedAmount === AMOUNT_FOR_CARD_VALIDATION) && + ( + data.isCardLinkOperation || + Boolean(data.promoCodeId) || + data.chargeAmount === undefined || + expectedAmount === plan.monthlyCharge + ); - /** - * Revalidate promo before accepting payment. - * - * Amount check uses server-side pricing; usage is recorded later in /pay - * after workspace plan is updated successfully. - */ - if (data.promo && !data.isCardLinkOperation) { - try { - const promoCodeService = new PromoCodeService(context.factories); + if (!hasValidSignedAmount || +body.Amount !== expectedAmount) { + this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Amount does not match signed payment data', body); - promoPricing = await promoCodeService.getPricingForPromoCodeId( - data.promo.id, - data.userId, - data.workspaceId, - plan + return; + } + + const recurrentSettings = data.cloudPayments?.recurrent; + + if (recurrentSettings) { + const startDateTime = recurrentSettings.startDate + ? new Date(recurrentSettings.startDate).getTime() + : undefined; + const signedStartDateTime = data.nextPaymentDate + ? new Date(data.nextPaymentDate).getTime() + : undefined; + const isStartDateValid = startDateTime === undefined || + ( + Number.isFinite(startDateTime) && + startDateTime === signedStartDateTime ); - } catch (e) { - const error = e as Error; + const isValidRecurrentSettings = + recurrentSettings.interval === (workspace.isDebug ? 'Day' : 'Month') && + recurrentSettings.period === 1 && + recurrentSettings.amount !== undefined && + +recurrentSettings.amount === plan.monthlyCharge && + isStartDateValid; - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo code is invalid: ${error.toString()}`, body); + if (!isValidRecurrentSettings) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] Invalid recurrent payment settings', 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); + const promoCodeService = (context as typeof context & PromoCodeContext).promoCodeService; + let reservationCreated = false; - if (!isRightAmount) { - this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); + if (data.promoCodeId) { + try { + const reservation = await promoCodeService.reserve({ + transactionId: body.TransactionId.toString(), + promoCodeId: data.promoCodeId, + userId, + workspaceId: workspace._id, + plan, + utm: data.promoUtm, + }); - return; + reservationCreated = reservation.created; + + if (reservation.finalAmount !== expectedAmount) { + if (reservationCreated) { + try { + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Check] Failed to release outdated promo reservation', releaseError); + } + } + + this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo price has changed', body); + + return; + } + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo cannot be reserved: ${error.toString()}`, body); + + return; + } } /** * Create business operation about creation of subscription */ try { - await context.factories.businessOperationsFactory.create({ - transactionId: body.TransactionId.toString(), - type: data.isCardLinkOperation ? BusinessOperationType.CardLinkCharge : BusinessOperationType.WorkspacePlanPurchase, - status: BusinessOperationStatus.Pending, - payload: { - workspaceId: workspace._id, - amount: +body.Amount * PENNY_MULTIPLIER, - currency: body.Currency, - userId: member._id, - tariffPlanId: plan._id, - }, - dtCreated: new Date(), - }); + const transactionId = body.TransactionId.toString(); + const existingOperation = await context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId); + + if (!existingOperation) { + await context.factories.businessOperationsFactory.create({ + transactionId, + type: data.isCardLinkOperation ? BusinessOperationType.CardLinkCharge : BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId: workspace._id, + amount: +body.Amount * PENNY_MULTIPLIER, + currency: body.Currency, + userId: member._id, + tariffPlanId: plan._id, + }, + dtCreated: new Date(), + }); + } } catch (err) { const error = err as Error; - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Business operation wasn't created: ${error.toString()}`, body); + if (reservationCreated) { + try { + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Check] Failed to release promo reservation', releaseError); + } + } - res.json({ - code: CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, - } as CheckResponse); + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Business operation wasn't created: ${error.toString()}`, body); return; } @@ -273,24 +299,15 @@ export default class CloudPaymentsWebhooks { } catch (e) { const error = e as Error; - this.sendError(res, CheckCodes.SUCCESS, `[Billing / Pay] Invalid request: ${error.toString()}`, body); + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Invalid request: ${error.toString()}`, body); return; } - /** Data validation */ - if (data.isCardLinkOperation) { - if (!data.userId || !data.workspaceId) { - this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace or user id in request body', req.body); + if (!data.workspaceId || !data.tariffPlanId || !data.userId) { + this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace, tariff plan or user id in request body', body); - return; - } - } else { - if (!data.workspaceId || !data.tariffPlanId || !data.userId) { - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] No workspace, tariff plan or user id in request body`, body); - - return; - } + return; } let businessOperation; @@ -303,7 +320,7 @@ export default class CloudPaymentsWebhooks { businessOperation = await this.getBusinessOperation(req, body.TransactionId.toString()); workspace = await this.getWorkspace(req, data.workspaceId); user = await this.getUser(req, data.userId); - planId = data.isCardLinkOperation ? workspace.tariffPlanId.toString() : data.tariffPlanId; + planId = data.tariffPlanId; tariffPlan = await this.getPlan(req, planId); } catch (e) { const error = e as Error; @@ -313,6 +330,20 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promoCodeId) { + try { + const promoCodeService = (req.context as typeof req.context & PromoCodeContext).promoCodeService; + + await promoCodeService.finalize(body.TransactionId.toString()); + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.TEMPORARY_ERROR, `[Billing / Pay] Promo cannot be finalized: ${error.toString()}`, body); + + return; + } + } + try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); @@ -341,27 +372,11 @@ export default class CloudPaymentsWebhooks { } catch (e) { const error = e as Error; - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + this.sendError(res, PayCodes.TEMPORARY_ERROR, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); 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; /* @@ -546,12 +561,20 @@ subscription id: ${body.SubscriptionId}`; */ private async fail(req: express.Request, res: express.Response): Promise { const body: FailRequest = req.body; - let data: PlanProlongationPayload; + let data: PaymentData; console.log('💎 CloudPayments /fail request', body); const failReasonLine = `reason: ${body.Reason} (${body.ReasonCode})`; + try { + const promoCodeService = (req.context as typeof req.context & PromoCodeContext).promoCodeService; + + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Fail] Promo reservation cannot be released', releaseError); + } + try { data = await this.getDataFromRequest(req); } catch (e) { @@ -564,10 +587,6 @@ subscription id: ${body.SubscriptionId}`; let workspace; let user; - /** - * @todo handle card linking and update business operation status - */ - if (!data.workspaceId || !data.userId || !data.tariffPlanId) { this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace or user id or plan id in request body\n${failReasonLine}`, body); @@ -630,15 +649,9 @@ subscription id: ${body.SubscriptionId || 'none'}` } /** - * Route executed when a CloudPayments subscription status changes. + * Route is executed if the status of the recurring payment subscription has been changed. * 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 */ @@ -845,17 +858,7 @@ status: ${body.Status}` } /** - * 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. + * Parses request body and returns data from it * * @param req - request with necessary data */ @@ -864,34 +867,16 @@ status: ${body.Status}` const body: CheckRequest | PayRequest | FailRequest = req.body; /** - * 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 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 (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 { ...checksumData, ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), - isCardLinkOperation: false, }; } @@ -949,7 +934,7 @@ 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 + * @param amount - charged amount */ private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string, amount = tariff.monthlyCharge): Promise { /** diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 4960e68a..0450f128 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,5 +1,3 @@ -import type { Utm } from '@hawk.so/types'; - /** * Data for setting up recurring payments */ @@ -20,8 +18,7 @@ interface RecurrentPaymentSettings { startDate?: string; /** - * Recurring payment amount for automatic subscription renewals. - * Keep it equal to the full plan price even when the first widget charge uses a promo. + * Recurring payment amount. */ amount?: number; } @@ -38,23 +35,6 @@ 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 @@ -76,12 +56,45 @@ 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 */ isCardLinkOperation: boolean; + + /** + * Amount signed by composePayment for the first charge. + * It is absent for automatic subscription renewals. + */ + chargeAmount?: number; + + /** + * Signed date for the first recurrent charge. + */ + nextPaymentDate?: string; + + /** + * Signed promo code reference. + */ + promoCodeId?: string; + + /** + * Signed promo attribution data. + */ + promoUtm?: Record; } + +export type PaymentChecksumData = PaymentData & { + chargeAmount: number; + nextPaymentDate: string; +}; + +/** + * Input kept compatible with short-lived checksums created before deployment. + * composePayment always supplies the complete PaymentChecksumData. + */ +export type PaymentChecksumInput = + Pick & + Partial> & { + nextPaymentDate: string; + }; diff --git a/src/billing/types/response.ts b/src/billing/types/response.ts index f5da282c..9aa2d973 100644 --- a/src/billing/types/response.ts +++ b/src/billing/types/response.ts @@ -79,6 +79,11 @@ export enum PayCodes { * Payment registered */ SUCCESS = 0, + + /** + * Temporary processing error. CloudPayments should retry the notification. + */ + TEMPORARY_ERROR = 13, } /** diff --git a/src/directives/requireAdmin.ts b/src/directives/requireAdmin.ts index 3acb3318..453b161b 100644 --- a/src/directives/requireAdmin.ts +++ b/src/directives/requireAdmin.ts @@ -80,14 +80,6 @@ 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 3c41d0c7..b5df818c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,8 +32,7 @@ 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'; +import PromoCodeService from './services/promoCodeService'; /** * Option to enable playground @@ -174,12 +173,6 @@ 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, @@ -187,8 +180,6 @@ class HawkAPI { plansFactory, businessOperationsFactory, releasesFactory, - promoCodesFactory, - promoCodeUsagesFactory, }; } @@ -236,14 +227,18 @@ class HawkAPI { * }); */ - return { + const context = { factories: HawkAPI.setupFactories(dataLoader), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + promoCodeService: new PromoCodeService(mongo.databases.hawk!), user: { id: userId, accessTokenExpired: isAccessTokenExpired, }, // accounting, }; + + return context; } /** diff --git a/src/models/promoCode.ts b/src/models/promoCode.ts deleted file mode 100644 index b612618f..00000000 --- a/src/models/promoCode.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 819ba10c..00000000 --- a/src/models/promoCodeUsage.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index a7f35697..00000000 --- a/src/models/promoCodeUsagesFactory.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 78570c0a..00000000 --- a/src/models/promoCodesFactory.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 2ed99e1b..26c696db 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -7,7 +7,6 @@ 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 @@ -140,7 +139,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?: Utm; + public utm?: UserDBScheme['utm']; /** * External identities for SSO (keyed by workspaceId) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 1939002c..8e1869f8 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -3,7 +3,6 @@ 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 @@ -67,7 +66,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 5604d09b..c290aa03 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -5,17 +5,18 @@ import UserModel from '../models/user'; import { BusinessOperationPayloadType, PayloadOfDepositByUser, - PayloadOfWorkspacePlanPurchase, - Utm + PayloadOfWorkspacePlanPurchase } 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'; +import { + PromoCodeContext, + PromoCodeError +} from '../services/promoCodeService'; +import { validateUtmParams } from '../utils/utm/utm'; /** * The amount we will debit to confirm the subscription. @@ -32,31 +33,21 @@ interface ComposePaymentArgs { tariffPlanId: string; shouldSaveCard?: boolean; promoCode?: string; - promoUtm?: Utm; + promoUtm?: Record; }; } /** - * Input data for promo code verification mutation. - */ -interface VerifyPromoCodeArgs { - input: { - workspaceId: string; - value: string; - }; -} - -/** - * Converts internal promo errors to public GraphQL errors. + * Maps expected promo errors to public GraphQL errors. * - * @param error - error to convert + * @param error - promo validation error */ -function throwPromoCodeGraphQLError(error: unknown): never { +function throwPromoCodeError(error: unknown): never { if (error instanceof PromoCodeError) { throw new UserInputError(error.code); } - throw new UserInputError(PromoCodeErrorCode.VerifyFailed); + throw error; } /** @@ -107,7 +98,7 @@ export default { async composePayment( _obj: undefined, { input }: ComposePaymentArgs, - { user, factories }: ResolverContextWithUser + { user, factories, promoCodeService }: ResolverContextWithUser & PromoCodeContext ): Promise<{ invoiceId: string; plan: { id: string; name: string; monthlyCharge: number }; @@ -117,13 +108,8 @@ export default { checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; - promo?: { - originalAmount: number; - finalAmount: number; - }; }> { 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'); @@ -161,23 +147,22 @@ export default { isCardLinkOperation = true; } - let tariffChargeAmount = plan.monthlyCharge; - let paymentPromoChecksum: PaymentPromoData | undefined; - let composePaymentPromo; + let chargeAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : plan.monthlyCharge; + let promoCodeId: string | undefined; 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, - }; + const quote = await promoCodeService.quote( + promoCode, + user.id, + workspace._id, + plan + ); + + chargeAmount = quote.finalAmount; + promoCodeId = quote.promoCodeId.toString(); } catch (error) { - throwPromoCodeGraphQLError(error); + throwPromoCodeError(error); } } @@ -193,26 +178,21 @@ export default { nextPaymentDate.setMonth(nextPaymentDate.getMonth() + 1); } - const checksumData = isCardLinkOperation - ? { - isCardLinkOperation: true as const, - workspaceId: workspace._id.toString(), - userId: user.id, - nextPaymentDate: nextPaymentDate.toISOString(), - } - : { - workspaceId: workspace._id.toString(), - userId: user.id, - tariffPlanId: plan._id.toString(), - shouldSaveCard: Boolean(shouldSaveCard), - nextPaymentDate: nextPaymentDate.toISOString(), - ...(paymentPromoChecksum ? { promo: paymentPromoChecksum } : {}), - }; + const promoUtm = validateUtmParams(input.promoUtm); + const checksumData = { + workspaceId: workspace._id.toString(), + userId: user.id, + tariffPlanId: plan._id.toString(), + shouldSaveCard: Boolean(shouldSaveCard), + isCardLinkOperation, + chargeAmount, + nextPaymentDate: nextPaymentDate.toISOString(), + ...(promoCodeId ? { promoCodeId } : {}), + ...(promoUtm && Object.keys(promoUtm).length > 0 ? { promoUtm } : {}), + }; const checksum = await checksumService.generateChecksum(checksumData); - const chargeAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : tariffChargeAmount; - /** * Send info to Telegram (non-blocking) */ @@ -241,7 +221,6 @@ debug: ${Boolean(workspace.isDebug)}` checksum, nextPaymentDate, cloudPaymentsPublicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', - promo: composePaymentPromo, }; }, }, @@ -314,36 +293,6 @@ 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 * @@ -355,7 +304,13 @@ debug: ${Boolean(workspace.isDebug)}` async payWithCard(_obj: undefined, args: PayWithCardArgs, { factories, user }: ResolverContextWithUser): Promise { const paymentData = checksumService.parseAndVerifyChecksum(args.input.checksum); - if (!('tariffPlanId' in paymentData)) { + if ( + !paymentData.workspaceId || + !paymentData.tariffPlanId || + paymentData.userId !== user.id || + !Number.isFinite(paymentData.chargeAmount) || + paymentData.chargeAmount <= 0 + ) { throw new UserInputError('Invalid checksum'); } @@ -381,7 +336,6 @@ debug: ${Boolean(workspace.isDebug)}` }; const isTariffPlanExpired = workspace.isTariffPlanExpired(); - const dueDate = workspace.getTariffPlanDueDate(); if (args.input.isRecurrent) { const interval = workspace.isDebug ? 'Day' : 'Month'; @@ -390,11 +344,6 @@ debug: ${Boolean(workspace.isDebug)}` recurrent: { interval, period: 1, - /** - * Subscription renewals must always bill the full plan price. - * Amount below may be promo-discounted or reduced for card validation, - * but CloudPayments uses recurrent.amount for future automatic charges. - */ amount: plan.monthlyCharge, }, }; @@ -404,26 +353,13 @@ debug: ${Boolean(workspace.isDebug)}` * we need to withdraw money only after tariff plan expired */ if (!isTariffPlanExpired) { - jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString(); + jsonData.cloudPayments.recurrent.startDate = paymentData.nextPaymentDate; } } - let amount = plan.monthlyCharge; - - const isPaymentForCurrentTariffPlan = workspace.tariffPlanId.toString() === plan._id.toString(); - - /** - * True when we need to withdraw the amount only to validate the subscription - */ - const isOnlyCardValidationNeeded = args.input.isRecurrent && isPaymentForCurrentTariffPlan && !isTariffPlanExpired; - - if (isOnlyCardValidationNeeded) { - amount = AMOUNT_FOR_CARD_VALIDATION; - } - const result = await cloudPaymentsApi.payByToken({ AccountId: user.id, - Amount: amount, + Amount: paymentData.chargeAmount, Token: token, Currency: 'RUB', JsonData: jsonData, diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index 7abb7f3b..7cadb46a 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?: Utm }, + { email, utm }: { email: string; utm?: UserDBScheme['utm'] }, { factories }: ResolverContextBase ): Promise { const validatedUtm = validateUtmParams(utm); diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts index 4db4c175..866f0de5 100644 --- a/src/services/promoCodeService.ts +++ b/src/services/promoCodeService.ts @@ -1,450 +1,387 @@ -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'; +import { Collection, Db, ObjectId } from 'mongodb'; const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; -const DEFAULT_MIN_FINAL_PRICE = 1; +const MIN_PRICE = 1; +const RESERVATION_TTL_MS = 30 * 60 * 1000; + +interface PromoBenefit { + type: string; + percent?: number; + amount?: number; + minFinalPrice?: number; + applicablePlanIds?: ObjectId[]; +} + +interface PromoCodeDocument { + _id: ObjectId; + value: string; + benefit: PromoBenefit; + limit?: number; + expiresAt?: Date; +} + +interface PromoUsageDocument { + _id: ObjectId; + transactionId: string; + promoCodeId: ObjectId; + userId: string; + workspaceId: ObjectId; + planId: ObjectId; + benefitType: string; + originalAmount: number; + finalAmount: number; + discountAmount: number; + status: 'reserved' | 'applied'; + ordinal?: number; + reservationExpiresAt?: Date; + appliedAt?: Date; + utm?: Record; +} + +interface PromoPlan { + _id: ObjectId; + monthlyCharge: number; + isHidden?: boolean; +} -/** - * 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) { + constructor(public readonly code: PromoCodeErrorCode, message: string) { 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. - */ +export interface PromoQuote { + promoCodeId: ObjectId; + benefitType: 'percent_discount' | 'fixed_price'; 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(); +export interface PromoReservation extends PromoQuote { + created: boolean; } -/** - * 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); +export interface PromoCodeContext { + promoCodeService: PromoCodeService; } -function isSupportedPromoCodeBenefitType(type: PromoCodeBenefitType): boolean { - return type === 'percent_discount' || type === 'fixed_price'; -} +export default class PromoCodeService { + private readonly promoCodes: Collection; + private readonly usages: Collection; -/** - * 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'); + constructor(db: Db) { + this.promoCodes = db.collection('promoCodes'); + this.usages = db.collection('promoCodeUsages'); } -} - -/** - * 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'); - } - if ( - benefit.minFinalPrice !== undefined && - (typeof benefit.minFinalPrice !== 'number' || - !Number.isFinite(benefit.minFinalPrice) || - benefit.minFinalPrice < DEFAULT_MIN_FINAL_PRICE) - ) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Minimum final price is invalid'); - } + public async quote(value: string, userId: string, workspaceId: ObjectId, plan: PromoPlan): Promise { + const normalizedValue = value.trim().toUpperCase(); - return; + if (!PROMO_CODE_REGEXP.test(normalizedValue)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code'); + } - case 'fixed_price': - if (typeof benefit.amount !== 'number' || benefit.amount < DEFAULT_MIN_FINAL_PRICE) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Fixed price is invalid'); - } + const promoCode = await this.promoCodes.findOne({ value: normalizedValue }); - return; + if (!promoCode) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + } - default: - if (!isSupportedPromoCodeBenefitType(benefit.type)) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); - } + await this.deleteExpiredReservations(promoCode._id); + await this.assertAvailable(promoCode, userId, workspaceId); - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Unknown benefit type'); + return this.calculateQuote(promoCode, plan); } -} -/** - * 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 } : {}), - }; -} + public async reserve(params: { + transactionId: string; + promoCodeId: string; + userId: string; + workspaceId: ObjectId; + plan: PromoPlan; + utm?: Record; + }): Promise { + if (!ObjectId.isValid(params.promoCodeId)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code id'); + } -/** - * 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; - } + const promoCodeId = new ObjectId(params.promoCodeId); + + await this.deleteExpiredReservations(promoCodeId); - /** - * 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 existingUsage = await this.usages.findOne({ transactionId: params.transactionId }); + + if (existingUsage) { + return this.toReservation(this.assertSameTransaction(existingUsage, params), false); } - const promoCode = await this.factories.promoCodesFactory.findByValue(normalizedValue); + const promoCode = await this.promoCodes.findOne({ _id: promoCodeId }); if (!promoCode) { throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); } - await this.validateLoadedPromoCode(promoCode, userId, workspaceId); + await this.assertAvailable(promoCode, params.userId, params.workspaceId); + + const quote = this.calculateQuote(promoCode, params.plan); + const usage = { + _id: new ObjectId(), + transactionId: params.transactionId, + promoCodeId: quote.promoCodeId, + userId: params.userId, + workspaceId: params.workspaceId, + planId: params.plan._id, + benefitType: quote.benefitType, + originalAmount: quote.originalAmount, + finalAmount: quote.finalAmount, + discountAmount: quote.discountAmount, + status: 'reserved' as const, + reservationExpiresAt: new Date(Date.now() + RESERVATION_TTL_MS), + ...(params.utm && Object.keys(params.utm).length > 0 ? { utm: params.utm } : {}), + }; - return promoCode; - } + while (true) { + let ordinal: number | undefined; + + if (typeof promoCode.limit === 'number') { + const [count, lastUsage] = await Promise.all([ + this.usages.countDocuments({ promoCodeId: promoCode._id }), + this.usages.findOne( + { + promoCodeId: promoCode._id, + ordinal: { $exists: true }, + }, + { + sort: { ordinal: -1 }, + projection: { ordinal: 1 }, + } + ), + ]); + + if (count >= promoCode.limit) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code limit exceeded'); + } + + ordinal = (lastUsage?.ordinal ?? -1) + 1; + } - /** - * 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'); + try { + await this.usages.insertOne({ + ...usage, + ...(ordinal !== undefined ? { ordinal } : {}), + }); + + return { + ...quote, + created: true, + }; + } catch (error) { + if ((error as { code?: number }).code !== 11000) { + throw error; + } + + const transactionUsage = await this.usages.findOne({ transactionId: params.transactionId }); + + if (transactionUsage) { + return this.toReservation(this.assertSameTransaction(transactionUsage, params), false); + } + + const conflictingUsage = await this.findUserOrWorkspaceUsage( + promoCode._id, + params.userId, + params.workspaceId + ); + + if (conflictingUsage || ordinal === undefined) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code was already used'); + } + } } + } - const promoCode = await this.factories.promoCodesFactory.findOne({ _id: new ObjectId(promoCodeId) }); + public async finalize(transactionId: string): Promise { + const usage = await this.usages.findOne({ transactionId }); - if (!promoCode) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + if (!usage) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo reservation 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; + if (usage.status === 'applied') { + return; + } - const result: PromoCodeVerifyResult = { - value: promoCode.value, - benefitType: benefit.type, - }; + const result = await this.usages.updateOne( + { + _id: usage._id, + status: 'reserved', + }, + { + $set: { + status: 'applied', + appliedAt: new Date(), + }, + $unset: { reservationExpiresAt: '' }, + } + ); - if (benefit.type === 'percent_discount') { - result.percent = benefit.percent; + if (result.modifiedCount !== 1) { + const currentUsage = await this.usages.findOne({ transactionId }); - if (benefit.minFinalPrice !== undefined) { - result.minFinalPrice = benefit.minFinalPrice; + if (currentUsage?.status === 'applied') { + return; } + + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo reservation was not finalized'); } + } + + public async release(transactionId: string): Promise { + await this.usages.deleteOne({ + transactionId, + status: 'reserved', + }); + } - if (benefit.type === 'fixed_price') { - result.amount = benefit.amount; + private async assertAvailable(promoCode: PromoCodeDocument, userId: string, workspaceId: ObjectId): Promise { + if (promoCode.expiresAt && promoCode.expiresAt.getTime() < Date.now()) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); } if ( - (benefit.type === 'percent_discount' || benefit.type === 'fixed_price') && - benefit.applicablePlanIds?.length + promoCode.limit !== undefined && + (!Number.isInteger(promoCode.limit) || promoCode.limit <= 0) ) { - result.applicablePlanIds = benefit.applicablePlanIds.map((planId): string => planId.toString()); + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code limit'); } - 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'); - } + const [conflictingUsage, usageCount] = await Promise.all([ + this.findUserOrWorkspaceUsage(promoCode._id, userId, workspaceId), + typeof promoCode.limit === 'number' + ? this.usages.countDocuments({ promoCodeId: promoCode._id }) + : Promise.resolve(0), + ]); - throw error; + if (conflictingUsage || (typeof promoCode.limit === 'number' && usageCount >= promoCode.limit)) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code limit exceeded'); } } - /** - * 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, + private async findUserOrWorkspaceUsage( + promoCodeId: ObjectId, userId: string, - workspaceId: string - ): Promise { - if (promoCode.expiresAt && new Date() > new Date(promoCode.expiresAt)) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); - } + workspaceId: ObjectId + ): Promise { + return this.usages.findOne({ + promoCodeId, + $or: [ + { userId }, + { workspaceId }, + ], + }); + } - assertSupportedBenefitType(promoCode.benefit); - validateBenefitStructure(promoCode.benefit); - await this.validateUsageLimits(promoCode, userId, new ObjectId(workspaceId)); + private async deleteExpiredReservations(promoCodeId: ObjectId): Promise { + await this.usages.deleteMany({ + promoCodeId, + status: 'reserved', + reservationExpiresAt: { $lte: new Date() }, + }); } - /** - * 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 { + private calculateQuote(promoCode: PromoCodeDocument, plan: PromoPlan): PromoQuote { const benefit = promoCode.benefit; + const applicablePlanIds = benefit.applicablePlanIds ?? []; + const appliesToPlan = applicablePlanIds.length === 0 || + applicablePlanIds.some(planId => planId.toString() === plan._id.toString()); - if (benefit.type !== 'percent_discount' && benefit.type !== 'fixed_price') { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo benefit type is not supported'); + if (plan.isHidden || plan.monthlyCharge <= 0 || !appliesToPlan) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not applicable to this plan'); } - const price = calculatePromoCodePlanPrice(benefit, plan); + let finalAmount: number; + + if (benefit.type === 'percent_discount') { + if ( + !Number.isFinite(benefit.percent) || + benefit.percent === undefined || + benefit.percent <= 0 || + benefit.percent > 100 || + ( + benefit.minFinalPrice !== undefined && + ( + !Number.isInteger(benefit.minFinalPrice) || + benefit.minFinalPrice < MIN_PRICE + ) + ) + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid percent discount'); + } + + const discount = Math.floor(plan.monthlyCharge * benefit.percent / 100); + + finalAmount = Math.max(plan.monthlyCharge - discount, benefit.minFinalPrice ?? MIN_PRICE); + } else if (benefit.type === 'fixed_price') { + if ( + !Number.isInteger(benefit.amount) || + benefit.amount === undefined || + benefit.amount < MIN_PRICE + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid fixed price'); + } + + finalAmount = benefit.amount; + } else { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Unsupported promo code'); + } - if (!price.isApplicable) { - throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not applicable to selected plan'); + if (finalAmount >= plan.monthlyCharge) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code does not reduce the price'); } return { - promoCode, - benefitType: promoCode.benefit.type, - originalAmount: price.originalAmount, - finalAmount: price.finalAmount, - discountAmount: price.discountAmount, + promoCodeId: promoCode._id, + benefitType: benefit.type, + originalAmount: plan.monthlyCharge, + finalAmount, + discountAmount: plan.monthlyCharge - finalAmount, }; } - /** - * 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'); + private assertSameTransaction( + usage: PromoUsageDocument, + params: { + promoCodeId: string; + userId: string; + workspaceId: ObjectId; + plan: PromoPlan; } - - if (userUsage || workspaceUsage) { - throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo per user or workspace limit exceeded'); + ): PromoUsageDocument { + if ( + usage.promoCodeId.toString() !== params.promoCodeId || + usage.userId !== params.userId || + usage.workspaceId.toString() !== params.workspaceId.toString() || + usage.planId.toString() !== params.plan._id.toString() + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Transaction has another promo reservation'); } + + return usage; + } + + private toReservation(usage: PromoUsageDocument, created: boolean): PromoReservation { + return { + promoCodeId: usage.promoCodeId, + benefitType: usage.benefitType as PromoQuote['benefitType'], + originalAmount: usage.originalAmount, + finalAmount: usage.finalAmount, + discountAmount: usage.discountAmount, + created, + }; } } diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index d45bb817..8544a684 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -49,7 +49,7 @@ type ComposePaymentPlanInfo { name: String! """ - Monthly charge for plan (full tariff price) + Monthly charge for plan """ monthlyCharge: Int! } @@ -237,91 +237,16 @@ input ComposePaymentInput { shouldSaveCard: Boolean """ - Promo code value entered by user + Promo code applied to the first payment """ promoCode: String """ - UTM parameters captured when promo code was applied + Promo attribution data """ 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! -} - """ Response of composePayment query """ @@ -337,9 +262,7 @@ type ComposePaymentResponse { plan: ComposePaymentPlanInfo! """ - Amount CloudPayments should charge now. - 1 RUB for card linking, promo.finalAmount when a promo applies, otherwise plan.monthlyCharge. - Not the same as promo: promo is an optional pricing breakdown and is absent on card-link payments. + Amount to charge in the first payment """ chargeAmount: Int! @@ -367,11 +290,6 @@ type ComposePaymentResponse { CloudPayments public id (merchant identifier for payment widget) """ cloudPaymentsPublicId: String! - - """ - Applied promo code data - """ - promo: ComposePaymentPromo } @@ -423,11 +341,6 @@ 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 640de5ae..cd0a5f36 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -6,8 +6,6 @@ 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 @@ -94,16 +92,6 @@ 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 38484fbf..0f682f93 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,53 +1,8 @@ import jwt, { Secret } from 'jsonwebtoken'; -import type { PaymentPromoData } from '../billing/types/paymentData'; - -export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; - -interface PlanPurchaseChecksumData { - /** - * Workspace Identifier - */ - workspaceId: string; - /** - * Id of the user making the payment - */ - userId: string; - /** - * Workspace current plan id or plan id to change - */ - tariffPlanId: string; - /** - * If true, we will save user card - */ - shouldSaveCard: boolean; - /** - * Next payment date - */ - nextPaymentDate: string; - /** - * Applied promo code data - */ - promo?: PaymentPromoData; -} - -interface CardLinkChecksumData { - /** - * Workspace Identifier - */ - workspaceId: string; - /** - * Id of the user making the payment - */ - userId: string; - /** - * True if this is card linking operation – charging minimal amount of money to validate card info - */ - isCardLinkOperation: boolean; - /** - * Next payment date - */ - nextPaymentDate: string; -} +import { + PaymentChecksumData, + PaymentChecksumInput +} from '../billing/types/paymentData'; /** * Helper class for working with checksums @@ -58,9 +13,13 @@ class ChecksumService { * * @param data - data for processing billing request */ - public async generateChecksum(data: ChecksumData): Promise { + public async generateChecksum(data: PaymentChecksumInput): Promise { return jwt.sign( - data, + { + ...data, + isCardLinkOperation: Boolean(data.isCardLinkOperation), + shouldSaveCard: Boolean(data.shouldSaveCard), + }, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, { expiresIn: '30m' } ); @@ -71,26 +30,20 @@ class ChecksumService { * * @param checksum - checksum to parse */ - public parseAndVerifyChecksum(checksum: string): ChecksumData { - const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as ChecksumData; - - if ('isCardLinkOperation' in payload) { - return { - workspaceId: payload.workspaceId, - userId: payload.userId, - isCardLinkOperation: payload.isCardLinkOperation, - nextPaymentDate: payload.nextPaymentDate, - }; - } else { - return { - workspaceId: payload.workspaceId, - userId: payload.userId, - tariffPlanId: payload.tariffPlanId, - shouldSaveCard: payload.shouldSaveCard, - nextPaymentDate: payload.nextPaymentDate, - promo: payload.promo, - }; - } + public parseAndVerifyChecksum(checksum: string): PaymentChecksumData { + const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as PaymentChecksumData; + + return { + workspaceId: payload.workspaceId, + userId: payload.userId, + tariffPlanId: payload.tariffPlanId, + shouldSaveCard: payload.shouldSaveCard, + isCardLinkOperation: payload.isCardLinkOperation, + chargeAmount: payload.chargeAmount, + nextPaymentDate: payload.nextPaymentDate, + promoCodeId: payload.promoCodeId, + promoUtm: payload.promoUtm, + }; } } diff --git a/src/utils/promoCodePricing.ts b/src/utils/promoCodePricing.ts deleted file mode 100644 index 8f35dc10..00000000 --- a/src/utils/promoCodePricing.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { - FixedPricePromoCodeBenefit, - PercentDiscountPromoCodeBenefit -} from '@hawk.so/types'; - -/** - * Default floor for percent-discount final price when benefit has no minFinalPrice - */ -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'); -} - -/** - * Whether plan can be purchased (hidden plans are not sellable) - * - * @param plan - pricing plan - */ -function isPlanAvailableForPurchase(plan: PromoCodePricingPlan): boolean { - return plan.isHidden !== true; -} - -/** - * Whether promo benefit applies to the given plan. - * Empty/missing applicablePlanIds means all plans. - * - * @param benefit - promo benefit - * @param plan - pricing plan - */ -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); -} - -/** - * Whether plan can receive a promo discount (paid + available for purchase) - * - * @param plan - pricing plan - */ -function isDiscountablePlan(plan: PromoCodePricingPlan): boolean { - return plan.monthlyCharge > 0 && isPlanAvailableForPurchase(plan); -} - -/** - * Floor for percent-discount final price. Invalid or missing values fall back to DEFAULT_MIN_FINAL_PRICE. - * - * @param minFinalPrice - optional floor from promo benefit - */ -function resolveMinFinalPrice(minFinalPrice?: number): number { - if (typeof minFinalPrice !== 'number' || !Number.isFinite(minFinalPrice)) { - return DEFAULT_MIN_FINAL_PRICE; - } - - return Math.max(minFinalPrice, DEFAULT_MIN_FINAL_PRICE); -} - -/** - * Calculates discounted price for one plan. - * - * Keep in sync with garage/src/utils/promoCodePricing.ts - * - * Unknown benefit types return the original price with isApplicable=false. - * - * @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 = resolveMinFinalPrice(benefit.minFinalPrice); - 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 754ef682..c3845e01 100644 --- a/src/utils/utm/utm.ts +++ b/src/utils/utm/utm.ts @@ -1,19 +1,7 @@ -import type { Utm } from '@hawk.so/types'; - /** * Valid UTM parameter keys */ -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); -} +const VALID_UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term']; /** * Regular expression for valid UTM characters @@ -31,16 +19,16 @@ const MAX_UTM_VALUE_LENGTH = 50; * @param {Object} utm - UTM parameters to validate * @returns {Object} - filtered valid UTM parameters */ -export function validateUtmParams(utm: unknown): Utm | undefined { +export function validateUtmParams(utm: any): Record | undefined { if (!utm || typeof utm !== 'object' || Array.isArray(utm)) { return undefined; } - const result: Utm = {}; + const result: Record = {}; for (const [key, value] of Object.entries(utm)) { // 1) Remove keys that are not VALID_UTM_KEYS - if (!isValidUtmKey(key)) { + if (!VALID_UTM_KEYS.includes(key)) { continue; } @@ -62,18 +50,3 @@ export function validateUtmParams(utm: unknown): Utm | 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 index 892cb910..7165b3c3 100644 --- a/test/billing/cloudpayments.test.ts +++ b/test/billing/cloudpayments.test.ts @@ -1,25 +1,18 @@ import '../../src/env-test'; -const cloudPaymentsClientMocks = { +const receiptApi = { 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), + getReceiptApi: () => receiptApi, + getClientApi: () => ({ + cancelSubscription: jest.fn().mockResolvedValue(undefined), }), })), - ReceiptTypes: { - Income: 'Income', - }, - TaxationSystem: { - SIMPLIFIED_INCOME: 'SIMPLIFIED_INCOME', - }, + ReceiptTypes: { Income: 'Income' }, + TaxationSystem: { SIMPLIFIED_INCOME: 'SIMPLIFIED_INCOME' }, })); jest.mock('../../src/utils/cloudPaymentsApi', () => ({ @@ -43,7 +36,7 @@ jest.mock('../../src/rabbitmq', () => ({ jest.mock('../../src/utils/telegram', () => ({ sendMessage: jest.fn().mockResolvedValue(undefined), - TelegramBotURLs: { Money: 'money-url' }, + TelegramBotURLs: { Money: 'money' }, })); jest.mock('../../src/utils/personalNotifications', () => ({ @@ -53,55 +46,29 @@ jest.mock('../../src/utils/personalNotifications', () => ({ jest.mock('@hawk.so/nodejs', () => ({ __esModule: true, - default: { - send: jest.fn(), - }, + default: { send: jest.fn() }, })); import { ObjectId } from 'mongodb'; import { - CardType, - Currency, - Interval, - OperationStatus, - OperationType, - ReasonCode, - SubscriptionStatus, -} from '../../src/billing/types/enums'; + BusinessOperationStatus, + BusinessOperationType +} from '@hawk.so/types'; import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; -import { CheckCodes, FailCodes, PayCodes, RecurrentCodes } from '../../src/billing/types'; +import { CheckCodes, FailCodes, PayCodes } 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) { +function createPlan() { return { _id: new ObjectId(), name: 'Basic', - monthlyCharge, + monthlyCharge: 1000, monthlyChargeCurrency: 'RUB', eventsLimit: 1000, isDefault: false, @@ -109,1038 +76,411 @@ function createPlan(monthlyCharge = 1000) { }; } -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); +function createContext(options: { + promoCodeId?: string; + reserve?: jest.Mock; + finalize?: jest.Mock; + release?: jest.Mock; + existingOperation?: boolean; + operationLookup?: jest.Mock; +} = {}) { + const plan = createPlan(); const workspace = { - _id: workspaceObjectId, - name: 'Test Workspace', - tariffPlanId: options.plan._id, - subscriptionId: options.subscriptionId ?? null, + _id: new ObjectId(), + name: 'Workspace', + tariffPlanId: plan._id, + subscriptionId: null, + isDebug: false, getMemberInfo: jest.fn().mockResolvedValue({ - _id: new ObjectId(options.userId), - userId: new ObjectId(options.userId), + _id: new ObjectId(), isAdmin: true, }), - changePlan, - setSubscriptionId, - }; - - const businessOperation = { - setStatus: jest.fn().mockResolvedValue(undefined), + changePlan: jest.fn().mockResolvedValue(1), + setSubscriptionId: jest.fn().mockResolvedValue(undefined), }; - const user = { - _id: new ObjectId(options.userId), + _id: new ObjectId(), 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 businessOperation = { + _id: new ObjectId(), + type: options.promoCodeId + ? BusinessOperationType.WorkspacePlanPurchase + : BusinessOperationType.CardLinkCharge, + status: BusinessOperationStatus.Pending, + setStatus: jest.fn().mockResolvedValue(undefined), + }; + const createOperation = jest.fn().mockResolvedValue(businessOperation); + const operationLookup = options.operationLookup ?? jest.fn().mockResolvedValue( + options.existingOperation ? businessOperation : null + ); + const promoCodeService = { + reserve: options.reserve ?? jest.fn().mockResolvedValue({ + created: true, + finalAmount: 750, + }), + finalize: options.finalize ?? jest.fn().mockResolvedValue(undefined), + release: options.release ?? jest.fn().mockResolvedValue(undefined), + }; const context = { + user: { + id: user._id.toString(), + accessTokenExpired: false, + }, factories: { workspacesFactory: { findById: jest.fn().mockResolvedValue(workspace), - findBySubscriptionId: options.findBySubscriptionIdImpl ?? jest.fn().mockResolvedValue(null), + findBySubscriptionId: jest.fn().mockResolvedValue(workspace), }, plansFactory: { - findById: jest.fn().mockResolvedValue(options.plan), + findById: jest.fn().mockResolvedValue(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, + getBusinessOperationByTransactionId: operationLookup, + create: createOperation, }, }, + promoCodeService, }; return { context, + plan, workspace, - changePlan, - createUsage, - businessOperation, - createBusinessOperation, user, + businessOperation, + createOperation, + operationLookup, + promoCodeService, + }; +} + +async function createData( + context: ReturnType, + options: { + chargeAmount?: number; + promoCodeId?: string; + isCardLinkOperation?: boolean; + } = {} +) { + const chargeAmount = options.chargeAmount ?? 1000; + const checksum = await checksumService.generateChecksum({ + workspaceId: context.workspace._id.toString(), + userId: context.user._id.toString(), + tariffPlanId: context.plan._id.toString(), + shouldSaveCard: false, + isCardLinkOperation: options.isCardLinkOperation ?? false, + chargeAmount, + nextPaymentDate: new Date('2026-10-01T00:00:00.000Z').toISOString(), + promoCodeId: options.promoCodeId, + }); + + return JSON.stringify({ + checksum, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: context.plan.monthlyCharge, + startDate: new Date('2026-10-01T00:00:00.000Z').toISOString(), + }, + }, + }); +} + +function createBody(transactionId: number, amount: number, Data: string) { + return { + TransactionId: transactionId, + Amount: amount.toString(), + Currency: 'RUB', + DateTime: new Date(), + TestMode: true, + Status: 'Completed', + OperationType: 'Payment', + CardType: 'Visa', + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Token: 'token', + IssuerBankCountry: 'RU', + Reason: 'DoNotHonor', + ReasonCode: 5001, + Data, }; } -function createMockResponse() { +function createResponse() { return { json: jest.fn(), }; } -describe('CloudPaymentsWebhooks', () => { +describe('CloudPayments promo flow', () => { 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 trust signed payment fields and only accept recurrent settings from widget Data', async () => { + // Arrange + const setup = createContext(); + const Data = JSON.parse(await createData(setup)); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + const result = await webhooks.getDataFromRequest({ + context: setup.context, + body: { + Data: JSON.stringify({ + ...Data, + workspaceId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + chargeAmount: 1, + }), + }, }); - 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(); + // Assert + expect(result).toMatchObject({ + workspaceId: setup.workspace._id.toString(), + tariffPlanId: setup.plan._id.toString(), + chargeAmount: 1000, + isCardLinkOperation: false, + cloudPayments: Data.cloudPayments, }); }); - 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 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 }); - }); + it('should reserve promo usage and create one pending operation', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1001, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: '1001', + promoCodeId, + })); + expect(setup.createOperation).toHaveBeenCalledWith(expect.objectContaining({ + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + })); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); }); - 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 keep an existing reservation and operation on /check retry', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const reserve = jest.fn().mockResolvedValue({ + created: false, + finalAmount: 750, }); - - 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 }); + const setup = createContext({ + promoCodeId, + reserve, + existingOperation: true, }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1002, 750, Data), + }, res); + + // Assert + expect(setup.createOperation).not.toHaveBeenCalled(); + expect(setup.promoCodeService.release).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.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 not release an existing reservation when operation lookup fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const reserve = jest.fn().mockResolvedValue({ + created: false, + finalAmount: 750, }); - - 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 }); + const setup = createContext({ + promoCodeId, + reserve, + operationLookup: jest.fn().mockRejectedValue(new Error('mongo down')), }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1003, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.release).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED }); }); - 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); + it('should reject unsigned amount changes before reserving promo usage', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1004, 1, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); - 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 accept 1 RUB only when card-link intent is signed', async () => { + // Arrange + const setup = createContext(); + const Data = await createData(setup, { + chargeAmount: 1, + isCardLinkOperation: true, }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1005, 1, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.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); + it('should finalize promo usage before granting the plan', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId, existingOperation: true }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.pay({ + context: setup.context, + body: createBody(2001, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.finalize).toHaveBeenCalledWith('2001'); + expect(setup.workspace.changePlan).toHaveBeenCalledWith(setup.plan._id); + expect(receiptApi.createReceipt).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + Items: [expect.objectContaining({ amount: 750, price: 750 })], + }) + ); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); - expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + it('should request a retry when promo finalization fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ + promoCodeId, + existingOperation: true, + finalize: jest.fn().mockRejectedValue(new Error('mongo down')), }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.pay({ + context: setup.context, + body: createBody(2002, 750, Data), + }, res); + + // Assert + expect(setup.workspace.changePlan).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.TEMPORARY_ERROR }); }); - 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); + it('should release a reservation when failed-payment checksum cannot be parsed', async () => { + // Arrange + const setup = createContext(); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3001, 750, '{ invalid json'), + }, res); + + // Assert + expect(setup.promoCodeService.release).toHaveBeenCalledWith('3001'); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); - expect(setSubscriptionId).toHaveBeenCalledWith(null); - expect(res.json).toHaveBeenCalledWith({ code: RecurrentCodes.SUCCESS }); + it('should release a reservation before loading the failed business operation', async () => { + // Arrange + const setup = createContext({ + operationLookup: jest.fn().mockRejectedValue(new Error('mongo down')), }); + const Data = await createData(setup); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3002, 1000, Data), + }, res); + + // Assert + expect(setup.promoCodeService.release).toHaveBeenCalledWith('3002'); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.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 }); + it('should continue failed-payment notification when reservation release fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ + promoCodeId, + existingOperation: true, + release: jest.fn().mockRejectedValue(new Error('mongo down')), }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3001, 750, Data), + }, res); + + // Assert + expect(setup.businessOperation.setStatus).toHaveBeenCalledWith(BusinessOperationStatus.Rejected); + expect(sendNotification).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); }); }); diff --git a/test/directives/requireAdmin.test.ts b/test/directives/requireAdmin.test.ts deleted file mode 100644 index 86e24c5d..00000000 --- a/test/directives/requireAdmin.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 query 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 query 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/check.test.ts b/test/integration/cases/billing/check.test.ts index ad199354..25ace68d 100644 --- a/test/integration/cases/billing/check.test.ts +++ b/test/integration/cases/billing/check.test.ts @@ -306,26 +306,30 @@ describe('Check webhook', () => { }); }); - test('Should allow request with amount = 1$, in case of deferred payment', async () => { + test('Should allow amount = 1 only for signed card linking', async () => { /** * Correct data */ + const nextPaymentDate = new Date().toISOString(); const data: CheckRequest = { ...mainRequest, + Amount: '1', Data: JSON.stringify({ checksum: await checksumService.generateChecksum({ workspaceId: workspace._id.toString(), userId: admin._id.toString(), tariffPlanId: planToChange._id.toString(), shouldSaveCard: false, - nextPaymentDate: new Date().toString(), + isCardLinkOperation: true, + chargeAmount: 1, + nextPaymentDate, }), cloudPayments: { recurrent: { interval: 'Month', period: 1, - startDate: new Date().toString(), - amount: 1, + startDate: nextPaymentDate, + amount: planToChange.monthlyCharge, }, }, }), diff --git a/test/integration/cases/billing/promo.test.ts b/test/integration/cases/billing/promo.test.ts deleted file mode 100644 index 5ba03544..00000000 --- a/test/integration/cases/billing/promo.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -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 d7483ada..03eacc94 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -238,8 +238,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -261,8 +259,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories, (req) => { @@ -284,8 +280,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -305,8 +299,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -329,8 +321,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -357,8 +347,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -393,8 +381,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -429,8 +415,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -486,8 +470,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -509,8 +491,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -534,8 +514,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -567,8 +545,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -602,8 +578,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -641,8 +615,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -681,8 +653,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -723,8 +693,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -760,8 +728,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -806,8 +772,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -866,8 +830,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -963,8 +925,6 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -1035,8 +995,6 @@ 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 815f0af3..483e8fde 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -1,14 +1,5 @@ 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: { @@ -18,12 +9,15 @@ jest.mock('../../src/utils/cloudPaymentsApi', () => ({ 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'; +import { + PromoCodeContext, + PromoCodeError, + PromoCodeErrorCode +} from '../../src/services/promoCodeService'; // Set environment variables for test process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; @@ -90,8 +84,9 @@ function createComposePaymentTestSetup(options: { const mockPlansFactory = { findById: jest.fn().mockResolvedValue(plan), }; + const quotePromoCode = jest.fn(); - const mockContext: ResolverContextWithUser = { + const mockContext = { user: { id: userId, accessTokenExpired: false, @@ -103,10 +98,11 @@ function createComposePaymentTestSetup(options: { projectsFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }, - }; + promoCodeService: { + quote: quotePromoCode, + } as any, + } as ResolverContextWithUser & PromoCodeContext; return { userId, @@ -117,100 +113,7 @@ function createComposePaymentTestSetup(options: { mockContext, mockWorkspacesFactory, mockPlansFactory, - }; -} - -/** - * 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, + quotePromoCode, }; } @@ -241,12 +144,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(false); - - const checksumData = checksumService.parseAndVerifyChecksum(result.checksum); - - if ('tariffPlanId' in checksumData) { - expect(checksumData.promo).toBeUndefined(); - } + expect(result.chargeAmount).toBe(1000); // Check that nextPaymentDate is one month from now const oneMonthFromNow = new Date(); @@ -282,6 +180,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(true); + expect(result.chargeAmount).toBe(1); const oneMonthFromLastChargeDate = new Date(workspace.lastChargeDate); oneMonthFromLastChargeDate.setMonth(oneMonthFromLastChargeDate.getMonth() + 1); @@ -311,6 +210,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(false); + expect(result.chargeAmount).toBe(1000); // Check that nextPaymentDate is one month from now const oneMonthFromNow = new Date(); @@ -323,255 +223,103 @@ describe('GraphQLBillingNew', () => { expect(nextPaymentDateStr).toBe(oneMonthFromNowStr); }); - it('should apply valid promo code and store promo id in checksum', async () => { + it('should return the server-calculated promo amount in the signed checksum', async () => { + // Arrange const promoCodeId = new ObjectId(); - const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + const { + mockContext, + planId, + workspaceId, + quotePromoCode, + } = createComposePaymentTestSetup({ isTariffPlanExpired: true, - isBlocked: false, - planMonthlyCharge: 1000, }); - withPromoFactories(mockContext, { - _id: promoCodeId, - value: 'SAVE25', - benefit: { - type: 'percent_discount', - percent: 25, - }, + quotePromoCode.mockResolvedValue({ + promoCodeId, + finalAmount: 750, }); + // Act const result = await billingNewResolver.Query.composePayment( undefined, { input: { workspaceId, tariffPlanId: planId, - shouldSaveCard: false, - promoCode: ' save25 ', + promoCode: 'save25', + promoUtm: { source: 'test' }, }, }, 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); + // Assert + expect(result.chargeAmount).toBe(750); expect(checksumData).toMatchObject({ - promo: { id: promoCodeId.toString() }, + tariffPlanId: planId, + isCardLinkOperation: false, + chargeAmount: 750, + promoCodeId: promoCodeId.toString(), + promoUtm: { source: 'test' }, }); }); - it('should reject invalid promo code', async () => { - const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + it('should map promo validation errors to a stable client code', async () => { + // Arrange + const { + mockContext, + planId, + workspaceId, + quotePromoCode, + } = 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 + quotePromoCode.mockRejectedValue( + new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found') ); - 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( + // Act + const promise = billingNewResolver.Query.composePayment( undefined, { input: { workspaceId, - value: 'save25', + tariffPlanId: planId, + promoCode: 'missing', }, }, mockContext ); - expect(result).toMatchObject({ - value: 'SAVE25', - benefitType: 'percent_discount', - percent: 25, - }); - expect(workspaceMock.changePlan).not.toHaveBeenCalled(); - }); - - it.each([ - [ - 'grant_plan', - { - type: 'grant_plan', - planId: new ObjectId(), - }, - ], - [ - 'amount_discount', - { - type: 'amount_discount', - amount: 100, - }, - ], - ])('should reject unsupported %s promo', async (_type, benefit) => { - const promoCodeId = new ObjectId(); - const { mockContext, workspaceId } = createVerifyPromoCodeTestSetup({ - promoCode: { - _id: promoCodeId, - value: 'UNSUPPORTED', - benefit, - }, - }); - - await expect( - billingNewResolver.Mutation.verifyPromoCode( - undefined, - { - input: { - workspaceId, - value: 'unsupported', - }, - }, - 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({ + // Assert + await expect(promise).rejects.toMatchObject({ message: PromoCodeErrorCode.Invalid, }); }); }); describe('payWithCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should charge full plan amount for recurrent payment', async () => { + it('should use signed first charge amount and full recurrent amount', async () => { + // Arrange const userId = new ObjectId().toString(); const workspaceId = new ObjectId().toString(); - const cardId = 'card-1'; - const newPlanId = new ObjectId(); + const planId = new ObjectId(); + const nextPaymentDate = new Date('2026-10-01T00:00:00.000Z').toISOString(); + const checksum = await checksumService.generateChecksum({ + workspaceId, + userId, + tariffPlanId: planId.toString(), + shouldSaveCard: false, + isCardLinkOperation: false, + chargeAmount: 750, + nextPaymentDate, + promoCodeId: new ObjectId().toString(), + }); const plan: PlanDBScheme = { - _id: newPlanId, + _id: planId, name: 'Test Plan', monthlyCharge: 1000, monthlyChargeCurrency: 'RUB', @@ -579,17 +327,7 @@ describe('GraphQLBillingNew', () => { 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 = { + const context = { user: { id: userId, accessTokenExpired: false, @@ -598,11 +336,10 @@ describe('GraphQLBillingNew', () => { workspacesFactory: { findById: jest.fn().mockResolvedValue({ _id: new ObjectId(workspaceId), - tariffPlanId: new ObjectId(), + tariffPlanId: planId, isDebug: false, getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }), - isTariffPlanExpired: jest.fn().mockReturnValue(true), - getTariffPlanDueDate: jest.fn().mockReturnValue(dueDate), + isTariffPlanExpired: jest.fn().mockReturnValue(false), }), } as any, plansFactory: { @@ -610,10 +347,7 @@ describe('GraphQLBillingNew', () => { } as any, usersFactory: { findById: jest.fn().mockResolvedValue({ - bankCards: [{ - id: cardId, - token: 'token-1', - }], + bankCards: [{ id: 'card-1', token: 'token-1' }], }), } as any, projectsFactory: {} as any, @@ -621,41 +355,40 @@ describe('GraphQLBillingNew', () => { getBusinessOperationByTransactionId: jest.fn().mockResolvedValue({ _id: new ObjectId() }), } as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }, - }; + } as ResolverContextWithUser; (cloudPaymentsApi.payByToken as jest.Mock).mockResolvedValue({ - Model: { - TransactionId: 999, - }, + Model: { TransactionId: 1001 }, }); + // Act await billingNewResolver.Mutation.payWithCard( undefined, { input: { checksum, - cardId, + cardId: 'card-1', isRecurrent: true, }, }, - mockContext + context ); - expect(cloudPaymentsApi.payByToken).toHaveBeenCalledWith( - expect.objectContaining({ - Amount: 1000, - JsonData: expect.objectContaining({ - cloudPayments: expect.objectContaining({ - recurrent: expect.objectContaining({ - amount: 1000, - }), - }), - }), - }) - ); + // Assert + expect(cloudPaymentsApi.payByToken).toHaveBeenCalledWith(expect.objectContaining({ + Amount: 750, + JsonData: expect.objectContaining({ + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: nextPaymentDate, + }, + }, + }), + })); }); }); -}) +}); diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index a01a05f6..00a4a907 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -125,8 +125,6 @@ function createMockContext(mockProject: ReturnType): R plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, - promoCodesFactory: {} as any, - promoCodeUsagesFactory: {} as any, }, }; } @@ -589,8 +587,6 @@ 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 index 3fd97b19..4333619e 100644 --- a/test/services/promoCodeService.test.ts +++ b/test/services/promoCodeService.test.ts @@ -1,386 +1,363 @@ import { ObjectId } from 'mongodb'; import PromoCodeService, { - normalizePromoCodeValue, - PromoCodeError, PromoCodeErrorCode } from '../../src/services/promoCodeService'; -import { calculatePromoCodePlanPrice } from '../../src/utils/promoCodePricing'; -function createPlan(overrides: Record = {}) { +function createPlan(monthlyCharge = 1000) { return { _id: new ObjectId(), - name: 'Basic', - monthlyCharge: 1000, - monthlyChargeCurrency: 'RUB', - eventsLimit: 1000, - isDefault: false, + monthlyCharge, isHidden: false, - ...overrides, - } as any; + }; } -function createPromoCode(benefit: Record, overrides: Record = {}) { +function createPromo(overrides: Record = {}) { return { _id: new ObjectId(), - value: 'PROMO', - benefit, - createdAt: new Date(), - updatedAt: new Date(), - createdBy: new ObjectId().toString(), + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, ...overrides, - } as any; + }; } -function createService(promoCode: any, options: { - totalUses?: number; - userUsage?: unknown; - workspaceUsage?: unknown; - plans?: any[]; - plan?: any; +function createService(options: { + promo?: ReturnType | null; + transactionUsage?: Record | null; + conflictingUsage?: Record | null; + usageCount?: number; + insertOne?: jest.Mock; + updateOne?: jest.Mock; } = {}) { - 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); -} + const promo = options.promo === undefined ? createPromo() : options.promo; + const promoCodes = { + findOne: jest.fn().mockResolvedValue(promo), + }; + const usages = { + findOne: jest.fn().mockImplementation((query) => { + if (query.transactionId) { + return Promise.resolve(options.transactionUsage ?? null); + } + + if (query.$or) { + return Promise.resolve(options.conflictingUsage ?? null); + } + + return Promise.resolve(null); + }), + countDocuments: jest.fn().mockResolvedValue(options.usageCount ?? 0), + insertOne: options.insertOne ?? jest.fn().mockResolvedValue({ insertedId: new ObjectId() }), + updateOne: options.updateOne ?? jest.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: jest.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + }; + const db = { + collection: jest.fn((name: string) => name === 'promoCodes' ? promoCodes : usages), + }; -async function expectPromoError(promise: Promise, code: PromoCodeErrorCode): Promise { - await expect(promise).rejects.toMatchObject({ - code, - } as PromoCodeError); + return { + service: new PromoCodeService(db as any), + promo, + promoCodes, + usages, + }; } describe('PromoCodeService', () => { - describe('normalizePromoCodeValue()', () => { - it('should trim and uppercase value before lookup', () => { - expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026'); + it('should normalize and calculate percent discount', async () => { + // Arrange + const { service, promo, promoCodes } = createService(); + const plan = createPlan(); + + // Act + const quote = await service.quote( + ' save25 ', + new ObjectId().toString(), + new ObjectId(), + plan + ); + + // Assert + expect(quote).toMatchObject({ + promoCodeId: promo?._id, + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, }); + expect(promoCodes.findOne).toHaveBeenCalledWith({ value: 'SAVE25' }); }); - 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 clamp non-positive minFinalPrice to default floor', () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const price = calculatePromoCodePlanPrice({ - type: 'percent_discount', - percent: 100, - minFinalPrice: 0, - } as any, plan); - - expect(price).toMatchObject({ - isApplicable: true, - originalAmount: 1000, - finalAmount: 1, - discountAmount: 999, - }); - }); - - it('should apply fixed price promo', () => { - const plan = createPlan({ monthlyCharge: 1000 }); - const price = calculatePromoCodePlanPrice({ + it('should calculate fixed price for an applicable plan', async () => { + // Arrange + const plan = createPlan(); + const promo = createPromo({ + benefit: { type: 'fixed_price', - amount: 299, - } as any, plan); - - expect(price.finalAmount).toBe(299); - expect(price.discountAmount).toBe(701); + amount: 100, + applicablePlanIds: [plan._id], + }, }); - - 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, - }); + const { service } = createService({ promo }); + + // Act + const quote = await service.quote( + promo.value, + new ObjectId().toString(), + new ObjectId(), + plan + ); + + // Assert + expect(quote).toMatchObject({ + finalAmount: 100, + discountAmount: 900, }); + }); - 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.each([ + { benefit: { type: 'grant_plan' } }, + { benefit: { type: 'percent_discount', percent: Number.NaN } }, + { benefit: { type: 'fixed_price', amount: Number.POSITIVE_INFINITY } }, + ])('should reject invalid benefit %#', async ({ benefit }) => { + // Arrange + const { service } = createService({ promo: createPromo({ benefit }) }); + + // Act + const promise = service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.Invalid, }); + }); - 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, - }); + it('should reject an expired promo code', async () => { + // Arrange + const expired = createPromo({ expiresAt: new Date(Date.now() - 1000) }); + const { service } = createService({ promo: expired }); + + // Act + const promise = service.quote( + expired.value, + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.Invalid, }); }); - 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 an already used promo code', async () => { + // Arrange + const used = createService({ + conflictingUsage: { _id: new ObjectId() }, }); - it('should reject unknown promo code', async () => { - const service = createService(null); - - await expectPromoError(service.verifyPromoCode('missing', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + // Act + const promise = used.service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.LimitExceeded, }); + }); - 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); + it('should delete expired reservations before checking availability', async () => { + // Arrange + const { service, usages } = createService(); + + // Act + await service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + expect(usages.deleteMany).toHaveBeenCalledWith(expect.objectContaining({ + status: 'reserved', + reservationExpiresAt: { + $lte: expect.any(Date), + }, + })); + }); - await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + it('should create a reservation with a pricing snapshot', async () => { + // Arrange + const { service, promo, usages } = createService(); + const plan = createPlan(); + + // Act + const reservation = await service.reserve({ + transactionId: 'tx-1', + promoCodeId: promo?._id.toString() as string, + userId: 'user-1', + workspaceId: new ObjectId(), + plan, + utm: { source: 'test' }, }); - 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); + // Assert + expect(reservation).toMatchObject({ + created: true, + finalAmount: 750, }); + expect(usages.insertOne).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: 'tx-1', + status: 'reserved', + finalAmount: 750, + utm: { source: 'test' }, + })); + }); - 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 return the existing reservation for the same transaction', async () => { + // Arrange + const plan = createPlan(); + const promo = createPromo(); + const workspaceId = new ObjectId(); + const transactionUsage = { + _id: new ObjectId(), + transactionId: 'tx-2', + promoCodeId: promo._id, + userId: 'user-2', + workspaceId, + planId: plan._id, + benefitType: 'percent_discount', + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + status: 'reserved', + }; + const { service, usages } = createService({ promo, transactionUsage }); + + // Act + const reservation = await service.reserve({ + transactionId: 'tx-2', + promoCodeId: promo._id.toString(), + userId: 'user-2', + workspaceId, + plan, }); - 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); + // Assert + expect(reservation).toMatchObject({ + created: false, + finalAmount: 750, }); + expect(usages.insertOne).not.toHaveBeenCalled(); + }); - 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); + it('should retry another ordinal after a concurrent reservation', async () => { + // Arrange + const promo = createPromo({ limit: 2 }); + const insertOne = jest.fn() + .mockRejectedValueOnce({ code: 11000 }) + .mockResolvedValueOnce({ insertedId: new ObjectId() }); + const { service, usages } = createService({ promo, insertOne }); + const plan = createPlan(); + const lastOrdinals = [-1, 0]; + + usages.countDocuments + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1); + usages.findOne.mockImplementation((query) => { + if (query.ordinal) { + const ordinal = lastOrdinals.shift(); + + return Promise.resolve(ordinal === undefined || ordinal < 0 ? null : { ordinal }); + } + + return Promise.resolve(null); }); - it.each([0, -1, Number.NaN])('should reject percent discount with invalid minFinalPrice %p', async (minFinalPrice) => { - const promoCode = createPromoCode({ - type: 'percent_discount', - percent: 50, - minFinalPrice, - }); - const service = createService(promoCode); + // Act + const reservation = await service.reserve({ + transactionId: 'tx-3', + promoCodeId: promo._id.toString(), + userId: 'user-3', + workspaceId: new ObjectId(), + plan, + }); - await expectPromoError(service.verifyPromoCode('promo', new ObjectId().toString(), new ObjectId().toString()), PromoCodeErrorCode.Invalid); + // Assert + expect(reservation).toMatchObject({ + created: true, }); + expect(insertOne).toHaveBeenNthCalledWith(1, expect.objectContaining({ ordinal: 0 })); + expect(insertOne).toHaveBeenNthCalledWith(2, expect.objectContaining({ ordinal: 1 })); }); - 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); + it('should finalize a reserved usage', async () => { + // Arrange + const reservedUsage = { + _id: new ObjectId(), + transactionId: 'tx-4', + status: 'reserved', + }; + const { service, usages } = createService({ transactionUsage: reservedUsage }); + + // Act + await service.finalize('tx-4'); + + // Assert + expect(usages.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ status: 'reserved' }), + expect.objectContaining({ + $set: expect.objectContaining({ status: 'applied' }), + $unset: { reservationExpiresAt: '' }, + }) + ); + }); - await expectPromoError( - service.getPricingForPlan('promo', new ObjectId().toString(), new ObjectId().toString(), plan), - PromoCodeErrorCode.Invalid - ); - }); + it('should release only a reserved usage', async () => { + // Arrange + const { service, usages } = createService(); - 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 - ); - }); + // Act + await service.release('tx-4'); - 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 - ); + // Assert + expect(usages.deleteOne).toHaveBeenCalledWith({ + transactionId: 'tx-4', + status: 'reserved', }); }); - 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 finalize an applied usage idempotently', async () => { + // Arrange + const reservedUsage = { + _id: new ObjectId(), + transactionId: 'tx-4', + status: 'reserved', + }; + const applied = createService({ + transactionUsage: { + ...reservedUsage, + status: 'applied', + }, }); - 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 - ); - }); + // Act + await applied.service.finalize('tx-4'); - 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); - }); + // Assert + expect(applied.usages.updateOne).not.toHaveBeenCalled(); }); }); diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 6a47b5de..307c9777 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -170,8 +170,6 @@ 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 7643004f..fffbdb21 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -1,4 +1,4 @@ -import { sanitizeUtmParams, validateUtmParams } from '../../src/utils/utm/utm'; +import { validateUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { @@ -136,19 +136,4 @@ 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 115c33f6..ff56e295 100644 --- a/yarn.lock +++ b/yarn.lock @@ -510,10 +510,10 @@ dependencies: bson "^7.0.0" -"@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== +"@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== dependencies: bson "^7.0.0" From 10969e66eb653cf484f55abbbebf9febbec8ffdb Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:28:08 +0000 Subject: [PATCH 43/43] Bump version up to 1.5.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db9cff2d..0e888e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.13", + "version": "1.5.14", "main": "index.ts", "license": "BUSL-1.1", "scripts": {