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/34] 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 2eb5eb563..ecfb35165 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 bcee09e0b..3b82632c4 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 fb554dd95..f7382fa5b 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 cb6f8d934..3c41d0c7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ import ReleasesFactory from './models/releasesFactory'; import RedisHelper from './redisHelper'; import { appendSsoRoutes } from './sso'; import { appendGitHubRoutes } from './integrations/github'; +import PromoCodesFactory from './models/promoCodesFactory'; +import PromoCodeUsagesFactory from './models/promoCodeUsagesFactory'; /** * Option to enable playground @@ -172,6 +174,12 @@ class HawkAPI { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const releasesFactory = new ReleasesFactory(mongo.databases.events!); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const promoCodeUsagesFactory = new PromoCodeUsagesFactory(mongo.databases.hawk!); + return { usersFactory, workspacesFactory, @@ -179,6 +187,8 @@ class HawkAPI { plansFactory, businessOperationsFactory, releasesFactory, + promoCodesFactory, + promoCodeUsagesFactory, }; } diff --git a/src/models/promoCode.ts b/src/models/promoCode.ts new file mode 100644 index 000000000..b612618f8 --- /dev/null +++ b/src/models/promoCode.ts @@ -0,0 +1,66 @@ +import { Collection, ObjectId } from 'mongodb'; +import AbstractModel from './abstractModel'; +import { + PromoCodeBenefit, + PromoCodeDBScheme +} from '@hawk.so/types'; + +/** + * Model representing promo code settings. + */ +export default class PromoCodeModel extends AbstractModel implements PromoCodeDBScheme { + /** + * Promo code id. + */ + public _id!: ObjectId; + + /** + * Normalized promo code value. + */ + public value!: string; + + /** + * Benefit granted by this promo code. + */ + public benefit!: PromoCodeBenefit; + + /** + * Maximum successful usages count. + */ + public limit?: number; + + /** + * Expiration date. + */ + public expiresAt?: Date; + + /** + * Creation date. + */ + public createdAt!: Date; + + /** + * Last update date. + */ + public updatedAt!: Date; + + /** + * Creator id. + */ + public createdBy!: string; + + /** + * Model's collection. + */ + protected collection: Collection; + + /** + * Create PromoCode instance. + * + * @param promoCodeData - promo code data + */ + constructor(promoCodeData: PromoCodeDBScheme) { + super(promoCodeData); + this.collection = this.dbConnection.collection('promoCodes'); + } +} diff --git a/src/models/promoCodeUsage.ts b/src/models/promoCodeUsage.ts new file mode 100644 index 000000000..819ba10c7 --- /dev/null +++ b/src/models/promoCodeUsage.ts @@ -0,0 +1,81 @@ +import { Collection, ObjectId } from 'mongodb'; +import AbstractModel from './abstractModel'; +import { + PromoCodeBenefitType, + PromoCodeUsageDBScheme +} from '@hawk.so/types'; + +/** + * Model representing successful promo code application. + */ +export default class PromoCodeUsageModel extends AbstractModel implements PromoCodeUsageDBScheme { + /** + * Promo code usage id. + */ + public _id!: ObjectId; + + /** + * Applied promo code id. + */ + public promoCodeId!: ObjectId; + + /** + * User who applied promo code. + */ + public userId!: string; + + /** + * Workspace where promo code was applied. + */ + public workspaceId!: ObjectId; + + /** + * Plan to which promo was applied. + */ + public planId?: ObjectId; + + /** + * Benefit type at application time. + */ + public benefitType!: PromoCodeBenefitType; + + /** + * Price before promo. + */ + public originalAmount?: number; + + /** + * Price after promo. + */ + public finalAmount?: number; + + /** + * Actual discount amount. + */ + public discountAmount?: number; + + /** + * UTM parameters captured on apply. + */ + public utm?: PromoCodeUsageDBScheme['utm']; + + /** + * Application date. + */ + public appliedAt!: Date; + + /** + * Model's collection. + */ + protected collection: Collection; + + /** + * Create PromoCodeUsage instance. + * + * @param usageData - usage data + */ + constructor(usageData: PromoCodeUsageDBScheme) { + super(usageData); + this.collection = this.dbConnection.collection('promoCodeUsages'); + } +} diff --git a/src/models/promoCodeUsagesFactory.ts b/src/models/promoCodeUsagesFactory.ts new file mode 100644 index 000000000..6009d099c --- /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 000000000..652fb3142 --- /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 ee973d221..3b0d70a04 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 7cf9197b1..df8d8b949 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 cd0a5f36b..640de5ae6 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -6,6 +6,8 @@ import ProjectsFactory from '../models/projectsFactory'; import PlansFactory from '../models/plansFactory'; import BusinessOperationsFactory from '../models/businessOperationsFactory'; import ReleasesFactory from '../models/releasesFactory'; +import PromoCodesFactory from '../models/promoCodesFactory'; +import PromoCodeUsagesFactory from '../models/promoCodeUsagesFactory'; /** * Resolver's Context argument @@ -92,6 +94,16 @@ export interface ContextFactories { * Releases factory for working with releases */ releasesFactory: ReleasesFactory; + + /** + * Promo codes factory for working with promo code settings + */ + promoCodesFactory: PromoCodesFactory; + + /** + * Promo code usages factory for working with successful applications + */ + promoCodeUsagesFactory: PromoCodeUsagesFactory; } /** diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 59601fb1c..a93048614 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 000000000..eff93e6ba --- /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 e1ffc6a9f..78dfa45ae 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 000000000..9fb418e5b --- /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 995f62677..eaa0281e4 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/34] 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 ecfb35165..c20d1fad3 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/34] 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 f7382fa5b..e60ab0169 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 26c696db4..2ed99e1b0 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -7,6 +7,7 @@ import objectHasOnlyProps from '../utils/objectHasOnlyProps'; import { NotificationsChannelsDBScheme } from '../types/notification-channels'; import { BankCard, UserDBScheme } from '@hawk.so/types'; import { v4 as uuid } from 'uuid'; +import type { Utm } from '@hawk.so/types'; /** * Utility type for making specific fields optional @@ -139,7 +140,7 @@ export default class UserModel extends AbstractModel> /** * UTM parameters from signup - Data form where user went to sign up. Used for analytics purposes */ - public utm?: UserDBScheme['utm']; + public utm?: Utm; /** * External identities for SSO (keyed by workspaceId) diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 8e1869f8e..1939002c1 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -3,6 +3,7 @@ import UserModel from './user'; import { Collection, Db, OptionalId } from 'mongodb'; import DataLoaders from '../dataLoaders'; import { UserDBScheme } from '@hawk.so/types'; +import type { Utm } from '@hawk.so/types'; /** * Users factory to work with User Model @@ -66,7 +67,7 @@ export default class UsersFactory extends AbstractModelFactory { const generatedPassword = password || (await UserModel.generatePassword()); const hashedPassword = await UserModel.hashPassword(generatedPassword); diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 3b0d70a04..7e07f4535 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 7cadb46af..653aefd29 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 df8d8b949..9c791bd8f 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 a93048614..2d8b20a77 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 eff93e6ba..1af31cbb5 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 c3845e01d..d87427cfc 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/34] 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 c20d1fad3..7e68ab31d 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 eaa0281e4..0ba7c2ae3 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/34] 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 6009d099c..f9b782abd 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 652fb3142..eb5080d44 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 653aefd29..7abb7f3b2 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/34] 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 1af31cbb5..b1f7e871f 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 9fb418e5b..8ae9dd90b 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/34] 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 3b82632c4..672a3cabb 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 e60ab0169..37fa8f710 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 7e07f4535..fe03dfd7f 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 9c791bd8f..b66684714 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 2d8b20a77..38484fbf2 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 b1f7e871f..5c5198452 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 03eacc94a..d7483ada9 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -238,6 +238,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -259,6 +261,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories, (req) => { @@ -280,6 +284,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -299,6 +305,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -321,6 +329,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -347,6 +357,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -381,6 +393,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -415,6 +429,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -470,6 +486,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -491,6 +509,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -514,6 +534,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -545,6 +567,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -578,6 +602,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -615,6 +641,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -653,6 +681,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -693,6 +723,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -728,6 +760,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -772,6 +806,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -830,6 +866,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -925,6 +963,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); @@ -995,6 +1035,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; setupRouter(factories); diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index 00a4a9074..a01a05f66 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -125,6 +125,8 @@ function createMockContext(mockProject: ReturnType): R plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; } @@ -587,6 +589,8 @@ describe('Project Resolver - createProject', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }, }; diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 307c9777e..6a47b5de1 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -170,6 +170,8 @@ describe('SamlController', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeUsagesFactory: {} as any, }; /** 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/34] 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 5c5198452..3378b6836 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 d87427cfc..61be76073 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/34] 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 37fa8f710..658e380a8 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 fe03dfd7f..a79f4d8d4 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 3378b6836..551773340 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/34] 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 672a3cabb..5783cfe7f 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 658e380a8..e97436325 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 a79f4d8d4..d048b0e53 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 551773340..917d659e2 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/34] 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 5783cfe7f..23515eab4 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 f9b782abd..a6d2184c9 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 eb5080d44..530f670ac 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/34] 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 453b161b3..3acb33189 100644 --- a/src/directives/requireAdmin.ts +++ b/src/directives/requireAdmin.ts @@ -80,6 +80,14 @@ export default function requireAdminDirective(directiveName = 'requireAdmin') { await checkByWorkspaceId(context, args.workspaceId); } + if (args.input?.workspaceId) { + await checkByWorkspaceId(context, args.input.workspaceId); + } + + if (args.projectId) { + await checkByProjectId(context, args.projectId); + } + if (args.input?.projectId) { await checkByProjectId(context, args.input.projectId); } diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d048b0e53..8dcfc135d 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 b66684714..76b2291eb 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/34] 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 8dcfc135d..0df302e7e 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/34] 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 0df302e7e..f11829db5 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 917d659e2..05b7c2dea 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/34] 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 23515eab4..45665fa84 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 f11829db5..0a3477307 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 05b7c2dea..e6430e4d1 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 000000000..865c0f97b --- /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 8ae9dd90b..000000000 --- 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/34] 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 45665fa84..b4d45bfcc 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/34] 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 78dfa45ae..868c4a347 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/34] 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 b4d45bfcc..10c7121b0 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 a6d2184c9..14a77e144 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 e6430e4d1..323104703 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 000000000..d7d981a03 --- /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 865c0f97b..3a8438be6 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/34] 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 14a77e144..6ecc48fb8 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 530f670ac..9508245c3 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 323104703..f93f4e63f 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/34] 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 10c7121b0..20c3fbeb1 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 0a3477307..55c9547b6 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 f93f4e63f..e8b8d0266 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 76b2291eb..376a311c7 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 61be76073..754ef6820 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 d7d981a03..fe0ca2bc0 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 000000000..739b337a9 --- /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 868c4a347..577039925 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 3a8438be6..2d3734735 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 fffbdb215..7643004fc 100644 --- a/test/utils/utm.test.ts +++ b/test/utils/utm.test.ts @@ -1,4 +1,4 @@ -import { validateUtmParams } from '../../src/utils/utm/utm'; +import { sanitizeUtmParams, validateUtmParams } from '../../src/utils/utm/utm'; describe('UTM Utils', () => { describe('validateUtmParams', () => { @@ -136,4 +136,19 @@ describe('UTM Utils', () => { }); }); }); + + describe('sanitizeUtmParams', () => { + it('should return undefined for invalid or empty UTM input', () => { + expect(sanitizeUtmParams(undefined)).toBeUndefined(); + expect(sanitizeUtmParams({ invalidKey: 'value' })).toBeUndefined(); + expect(sanitizeUtmParams({ source: 'bad@chars' })).toBeUndefined(); + }); + + it('should return sanitized UTM object for valid input', () => { + expect(sanitizeUtmParams({ source: 'google', medium: 'cpc' })).toEqual({ + source: 'google', + medium: 'cpc', + }); + }); + }); }); 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/34] 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 000000000..9e289a998 --- /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 6ecc48fb8..4fdfa007b 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 9508245c3..78570c0a5 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/34] 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 4fdfa007b..2e10213ac 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 55c9547b6..80984a100 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 e8b8d0266..ede09add8 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 376a311c7..78f3b16f6 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 000000000..e0927323c --- /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 739b337a9..08cd32002 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 577039925..9489d4186 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 2d3734735..c2c495040 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/34] 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 80984a100..5d11b16c7 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 ede09add8..386d5ce37 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 78f3b16f6..11b8c3365 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 08cd32002..c59e71589 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 9489d4186..d9fed062a 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 c2c495040..75ac55349 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/34] 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 e0927323c..c4a2f5c16 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/34] 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 9e289a998..c9997b36a 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 5d11b16c7..2bbd60e44 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/34] 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 fe0ca2bc0..bdf34dc2f 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 000000000..08a8e25e3 --- /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/34] 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 20c3fbeb1..dec02ba12 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 2e10213ac..a7f35697a 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 386d5ce37..0cd7793cc 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 bdf34dc2f..91283882c 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 75ac55349..6e7c2764e 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/34] 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 2bbd60e44..bf91509d8 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 11b8c3365..516f67b7c 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 d9fed062a..047bc28a2 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/34] 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 08a8e25e3..f92c5c36d 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/34] 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 bf91509d8..881bc5a5f 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 91283882c..9eacd6b11 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 6e7c2764e..24b4b58de 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/34] 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 dec02ba12..ba9dd777d 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 e97436325..4960e68a6 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 9eacd6b11..df6199213 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 f92c5c36d..5ba035444 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/34] 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 df6199213..9786178b2 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/34] 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 881bc5a5f..2cd830890 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 516f67b7c..c89effeda 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/34] 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 9786178b2..552746a0a 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();