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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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, }; /**