diff --git a/migrations/20260615140000-add-promo-code-indexes.js b/migrations/20260615140000-add-promo-code-indexes.js new file mode 100644 index 00000000..6dbcfda0 --- /dev/null +++ b/migrations/20260615140000-add-promo-code-indexes.js @@ -0,0 +1,40 @@ +/** + * @file Indexes required by promo code validation and reservation flow + */ +module.exports = { + async up(db) { + const promoCodes = db.collection('promoCodes'); + const usages = db.collection('promoCodeUsages'); + + await promoCodes.createIndex({ value: 1 }, { unique: true }); + await usages.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true }); + await usages.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true }); + await usages.createIndex( + { transactionId: 1 }, + { + unique: true, + partialFilterExpression: { transactionId: { $type: 'string' } }, + } + ); + await usages.createIndex( + { promoCodeId: 1, ordinal: 1 }, + { + unique: true, + partialFilterExpression: { ordinal: { $type: 'number' } }, + } + ); + await usages.createIndex({ reservationExpiresAt: 1 }, { expireAfterSeconds: 0 }); + }, + + async down(db) { + await db.collection('promoCodes').dropIndex('value_1'); + + const usages = db.collection('promoCodeUsages'); + + await usages.dropIndex('promoCodeId_1_userId_1'); + await usages.dropIndex('promoCodeId_1_workspaceId_1'); + await usages.dropIndex('transactionId_1'); + await usages.dropIndex('promoCodeId_1_ordinal_1'); + await usages.dropIndex('reservationExpiresAt_1'); + }, +}; diff --git a/package.json b/package.json index db9cff2d..0e888e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.13", + "version": "1.5.14", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index c24588aa..c81dcfa0 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -22,8 +22,7 @@ import { BusinessOperationType, ConfirmedMemberDBScheme, PayloadOfWorkspacePlanPurchase, - PlanDBScheme, - PlanProlongationPayload + PlanDBScheme } from '@hawk.so/types'; import WorkspaceModel from '../models/workspace'; import HawkCatcher from '@hawk.so/nodejs'; @@ -42,8 +41,10 @@ import { PaymentData } from './types/paymentData'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import PlanModel from '../models/plan'; import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments'; +import { PromoCodeContext } from '../services/promoCodeService'; const PENNY_MULTIPLIER = 100; +const AMOUNT_FOR_CARD_VALIDATION = 1; /** * Class for describing the logic of payment routes @@ -124,33 +125,22 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ - if (data.isCardLinkOperation) { - if (!data.userId || !data.workspaceId) { - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the card linking request', req.body); + if (!data.userId || !data.workspaceId || !data.tariffPlanId) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); - return; - } - } else { - if (!data.userId || !data.workspaceId || !data.tariffPlanId) { - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); - - return; - } + return; } let workspace: WorkspaceModel; let member: ConfirmedMemberDBScheme; - let plan: PlanDBScheme; - let planId: string; + let plan: PlanModel; const { workspaceId, userId, tariffPlanId } = data; try { workspace = await this.getWorkspace(req, workspaceId); member = await this.getMember(userId, workspace); - planId = data.isCardLinkOperation ? workspace.tariffPlanId.toString() : tariffPlanId; - plan = await this.getPlan(req, planId); + plan = await this.getPlan(req, tariffPlanId); } catch (e) { const error = e as Error; @@ -159,45 +149,124 @@ export default class CloudPaymentsWebhooks { return; } - const recurrentPaymentSettings = data.cloudPayments?.recurrent; + const expectedAmount = data.chargeAmount ?? plan.monthlyCharge; + const hasValidSignedAmount = + Number.isFinite(expectedAmount) && + expectedAmount > 0 && + (!data.isCardLinkOperation || expectedAmount === AMOUNT_FOR_CARD_VALIDATION) && + ( + data.isCardLinkOperation || + Boolean(data.promoCodeId) || + data.chargeAmount === undefined || + expectedAmount === plan.monthlyCharge + ); - /** - * 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; - - if (!isRightAmount) { - this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); + if (!hasValidSignedAmount || +body.Amount !== expectedAmount) { + this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Amount does not match signed payment data', body); return; } + const recurrentSettings = data.cloudPayments?.recurrent; + + if (recurrentSettings) { + const startDateTime = recurrentSettings.startDate + ? new Date(recurrentSettings.startDate).getTime() + : undefined; + const signedStartDateTime = data.nextPaymentDate + ? new Date(data.nextPaymentDate).getTime() + : undefined; + const isStartDateValid = startDateTime === undefined || + ( + Number.isFinite(startDateTime) && + startDateTime === signedStartDateTime + ); + const isValidRecurrentSettings = + recurrentSettings.interval === (workspace.isDebug ? 'Day' : 'Month') && + recurrentSettings.period === 1 && + recurrentSettings.amount !== undefined && + +recurrentSettings.amount === plan.monthlyCharge && + isStartDateValid; + + if (!isValidRecurrentSettings) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] Invalid recurrent payment settings', body); + + return; + } + } + + const promoCodeService = (context as typeof context & PromoCodeContext).promoCodeService; + let reservationCreated = false; + + if (data.promoCodeId) { + try { + const reservation = await promoCodeService.reserve({ + transactionId: body.TransactionId.toString(), + promoCodeId: data.promoCodeId, + userId, + workspaceId: workspace._id, + plan, + utm: data.promoUtm, + }); + + reservationCreated = reservation.created; + + if (reservation.finalAmount !== expectedAmount) { + if (reservationCreated) { + try { + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Check] Failed to release outdated promo reservation', releaseError); + } + } + + this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo price has changed', body); + + return; + } + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo cannot be reserved: ${error.toString()}`, body); + + return; + } + } + /** * Create business operation about creation of subscription */ try { - await context.factories.businessOperationsFactory.create({ - transactionId: body.TransactionId.toString(), - type: data.isCardLinkOperation ? BusinessOperationType.CardLinkCharge : BusinessOperationType.WorkspacePlanPurchase, - status: BusinessOperationStatus.Pending, - payload: { - workspaceId: workspace._id, - amount: +body.Amount * PENNY_MULTIPLIER, - currency: body.Currency, - userId: member._id, - tariffPlanId: plan._id, - }, - dtCreated: new Date(), - }); + const transactionId = body.TransactionId.toString(); + const existingOperation = await context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId); + + if (!existingOperation) { + await context.factories.businessOperationsFactory.create({ + transactionId, + type: data.isCardLinkOperation ? BusinessOperationType.CardLinkCharge : BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId: workspace._id, + amount: +body.Amount * PENNY_MULTIPLIER, + currency: body.Currency, + userId: member._id, + tariffPlanId: plan._id, + }, + dtCreated: new Date(), + }); + } } catch (err) { const error = err as Error; - this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Business operation wasn't created: ${error.toString()}`, body); + if (reservationCreated) { + try { + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Check] Failed to release promo reservation', releaseError); + } + } - res.json({ - code: CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, - } as CheckResponse); + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Business operation wasn't created: ${error.toString()}`, body); return; } @@ -230,24 +299,15 @@ export default class CloudPaymentsWebhooks { } catch (e) { const error = e as Error; - this.sendError(res, CheckCodes.SUCCESS, `[Billing / Pay] Invalid request: ${error.toString()}`, body); + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Invalid request: ${error.toString()}`, body); return; } - /** Data validation */ - if (data.isCardLinkOperation) { - if (!data.userId || !data.workspaceId) { - this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace or user id in request body', req.body); + if (!data.workspaceId || !data.tariffPlanId || !data.userId) { + this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace, tariff plan or user id in request body', body); - return; - } - } else { - if (!data.workspaceId || !data.tariffPlanId || !data.userId) { - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] No workspace, tariff plan or user id in request body`, body); - - return; - } + return; } let businessOperation; @@ -260,7 +320,7 @@ export default class CloudPaymentsWebhooks { businessOperation = await this.getBusinessOperation(req, body.TransactionId.toString()); workspace = await this.getWorkspace(req, data.workspaceId); user = await this.getUser(req, data.userId); - planId = data.isCardLinkOperation ? workspace.tariffPlanId.toString() : data.tariffPlanId; + planId = data.tariffPlanId; tariffPlan = await this.getPlan(req, planId); } catch (e) { const error = e as Error; @@ -270,6 +330,20 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promoCodeId) { + try { + const promoCodeService = (req.context as typeof req.context & PromoCodeContext).promoCodeService; + + await promoCodeService.finalize(body.TransactionId.toString()); + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.TEMPORARY_ERROR, `[Billing / Pay] Promo cannot be finalized: ${error.toString()}`, body); + + return; + } + } + try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); @@ -298,7 +372,7 @@ export default class CloudPaymentsWebhooks { } catch (e) { const error = e as Error; - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + this.sendError(res, PayCodes.TEMPORARY_ERROR, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); return; } @@ -442,7 +516,7 @@ plan monthly charge: ${data.cloudPayments?.recurrent.amount} ${body.Currency}` */ const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined; - await this.sendReceipt(workspace, tariffPlan, userEmail); + await this.sendReceipt(workspace, tariffPlan, userEmail, +body.Amount); let messageText = ''; @@ -487,12 +561,20 @@ subscription id: ${body.SubscriptionId}`; */ private async fail(req: express.Request, res: express.Response): Promise { const body: FailRequest = req.body; - let data: PlanProlongationPayload; + let data: PaymentData; console.log('๐Ÿ’Ž CloudPayments /fail request', body); const failReasonLine = `reason: ${body.Reason} (${body.ReasonCode})`; + try { + const promoCodeService = (req.context as typeof req.context & PromoCodeContext).promoCodeService; + + await promoCodeService.release(body.TransactionId.toString()); + } catch (releaseError) { + console.error('[Billing / Fail] Promo reservation cannot be released', releaseError); + } + try { data = await this.getDataFromRequest(req); } catch (e) { @@ -505,10 +587,6 @@ subscription id: ${body.SubscriptionId}`; let workspace; let user; - /** - * @todo handle card linking and update business operation status - */ - if (!data.workspaceId || !data.userId || !data.tariffPlanId) { this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace or user id or plan id in request body\n${failReasonLine}`, body); @@ -794,10 +872,11 @@ status: ${body.Status}` */ if (body.Data) { const parsedData = JSON.parse(body.Data || '{}') as WebhookData; + const checksumData = checksumService.parseAndVerifyChecksum(parsedData.checksum); return { - ...checksumService.parseAndVerifyChecksum(parsedData.checksum), - ...parsedData, + ...checksumData, + ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), }; } @@ -855,8 +934,9 @@ status: ${body.Status}` * @param workspace - workspace for which payment is made * @param tariff - paid tariff plan * @param userMail - user email address + * @param amount - charged 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 @@ -865,9 +945,9 @@ status: ${body.Status}` const VALUE_ADDED_TAX = 0; const item: CustomerReceiptItem = { - amount: tariff.monthlyCharge, + amount, label: `${tariff.name} tariff plan`, - price: tariff.monthlyCharge, + price: amount, vat: VALUE_ADDED_TAX, quantity: 1, }; diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index fb554dd9..0450f128 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -56,8 +56,45 @@ export interface PaymentData { * If true, we will save user card */ shouldSaveCard: boolean; + /** * True if this is card linking operation โ€“ charging minimal amount of money to validate card info */ isCardLinkOperation: boolean; + + /** + * Amount signed by composePayment for the first charge. + * It is absent for automatic subscription renewals. + */ + chargeAmount?: number; + + /** + * Signed date for the first recurrent charge. + */ + nextPaymentDate?: string; + + /** + * Signed promo code reference. + */ + promoCodeId?: string; + + /** + * Signed promo attribution data. + */ + promoUtm?: Record; } + +export type PaymentChecksumData = PaymentData & { + chargeAmount: number; + nextPaymentDate: string; +}; + +/** + * Input kept compatible with short-lived checksums created before deployment. + * composePayment always supplies the complete PaymentChecksumData. + */ +export type PaymentChecksumInput = + Pick & + Partial> & { + nextPaymentDate: string; + }; diff --git a/src/billing/types/response.ts b/src/billing/types/response.ts index f5da282c..9aa2d973 100644 --- a/src/billing/types/response.ts +++ b/src/billing/types/response.ts @@ -79,6 +79,11 @@ export enum PayCodes { * Payment registered */ SUCCESS = 0, + + /** + * Temporary processing error. CloudPayments should retry the notification. + */ + TEMPORARY_ERROR = 13, } /** diff --git a/src/index.ts b/src/index.ts index cb6f8d93..b5df818c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import ReleasesFactory from './models/releasesFactory'; import RedisHelper from './redisHelper'; import { appendSsoRoutes } from './sso'; import { appendGitHubRoutes } from './integrations/github'; +import PromoCodeService from './services/promoCodeService'; /** * Option to enable playground @@ -226,14 +227,18 @@ class HawkAPI { * }); */ - return { + const context = { factories: HawkAPI.setupFactories(dataLoader), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + promoCodeService: new PromoCodeService(mongo.databases.hawk!), user: { id: userId, accessTokenExpired: isAccessTokenExpired, }, // accounting, }; + + return context; } /** diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d5d96873..c290aa03 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -12,6 +12,11 @@ import { UserInputError } from 'apollo-server-express'; import cloudPaymentsApi, { CloudPaymentsJsonData } from '../utils/cloudPaymentsApi'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; +import { + PromoCodeContext, + PromoCodeError +} from '../services/promoCodeService'; +import { validateUtmParams } from '../utils/utm/utm'; /** * The amount we will debit to confirm the subscription. @@ -27,9 +32,24 @@ interface ComposePaymentArgs { workspaceId: string; tariffPlanId: string; shouldSaveCard?: boolean; + promoCode?: string; + promoUtm?: Record; }; } +/** + * Maps expected promo errors to public GraphQL errors. + * + * @param error - promo validation error + */ +function throwPromoCodeError(error: unknown): never { + if (error instanceof PromoCodeError) { + throw new UserInputError(error.code); + } + + throw error; +} + /** * Data for processing payment with saved card */ @@ -78,17 +98,18 @@ export default { async composePayment( _obj: undefined, { input }: ComposePaymentArgs, - { user, factories }: ResolverContextWithUser + { user, factories, promoCodeService }: ResolverContextWithUser & PromoCodeContext ): Promise<{ invoiceId: string; plan: { id: string; name: string; monthlyCharge: number }; + chargeAmount: number; isCardLinkOperation: boolean; currency: string; checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; }> { - const { workspaceId, tariffPlanId, shouldSaveCard } = input; + const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input; if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -126,6 +147,25 @@ export default { isCardLinkOperation = true; } + let chargeAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : plan.monthlyCharge; + let promoCodeId: string | undefined; + + if (promoCode && !isCardLinkOperation) { + try { + const quote = await promoCodeService.quote( + promoCode, + user.id, + workspace._id, + plan + ); + + chargeAmount = quote.finalAmount; + promoCodeId = quote.promoCodeId.toString(); + } catch (error) { + throwPromoCodeError(error); + } + } + /** * Calculate next payment date */ @@ -138,20 +178,18 @@ export default { nextPaymentDate.setMonth(nextPaymentDate.getMonth() + 1); } - const checksumData = isCardLinkOperation - ? { - isCardLinkOperation: true as const, - workspaceId: workspace._id.toString(), - userId: user.id, - nextPaymentDate: nextPaymentDate.toISOString(), - } - : { - workspaceId: workspace._id.toString(), - userId: user.id, - tariffPlanId: plan._id.toString(), - shouldSaveCard: Boolean(shouldSaveCard), - nextPaymentDate: nextPaymentDate.toISOString(), - }; + const promoUtm = validateUtmParams(input.promoUtm); + const checksumData = { + workspaceId: workspace._id.toString(), + userId: user.id, + tariffPlanId: plan._id.toString(), + shouldSaveCard: Boolean(shouldSaveCard), + isCardLinkOperation, + chargeAmount, + nextPaymentDate: nextPaymentDate.toISOString(), + ...(promoCodeId ? { promoCodeId } : {}), + ...(promoUtm && Object.keys(promoUtm).length > 0 ? { promoUtm } : {}), + }; const checksum = await checksumService.generateChecksum(checksumData); @@ -162,7 +200,7 @@ export default { .sendMessage(`๐Ÿ‘€ [Billing / Compose payment] card link operation: ${isCardLinkOperation} -amount: ${+plan.monthlyCharge} RUB +amount: ${chargeAmount} RUB last charge date: ${workspace.lastChargeDate?.toISOString()} next payment date: ${nextPaymentDate.toISOString()} workspace: ยซ${workspace.name}ยป (${workspace._id.toString()}) @@ -177,6 +215,7 @@ debug: ${Boolean(workspace.isDebug)}` name: plan.name, monthlyCharge: plan.monthlyCharge, }, + chargeAmount, isCardLinkOperation, currency: 'RUB', checksum, @@ -265,7 +304,13 @@ debug: ${Boolean(workspace.isDebug)}` async payWithCard(_obj: undefined, args: PayWithCardArgs, { factories, user }: ResolverContextWithUser): Promise { const paymentData = checksumService.parseAndVerifyChecksum(args.input.checksum); - if (!('tariffPlanId' in paymentData)) { + if ( + !paymentData.workspaceId || + !paymentData.tariffPlanId || + paymentData.userId !== user.id || + !Number.isFinite(paymentData.chargeAmount) || + paymentData.chargeAmount <= 0 + ) { throw new UserInputError('Invalid checksum'); } @@ -291,7 +336,6 @@ debug: ${Boolean(workspace.isDebug)}` }; const isTariffPlanExpired = workspace.isTariffPlanExpired(); - const dueDate = workspace.getTariffPlanDueDate(); if (args.input.isRecurrent) { const interval = workspace.isDebug ? 'Day' : 'Month'; @@ -300,6 +344,7 @@ debug: ${Boolean(workspace.isDebug)}` recurrent: { interval, period: 1, + amount: plan.monthlyCharge, }, }; @@ -308,27 +353,13 @@ debug: ${Boolean(workspace.isDebug)}` * we need to withdraw money only after tariff plan expired */ if (!isTariffPlanExpired) { - jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString(); - jsonData.cloudPayments.recurrent.amount = plan.monthlyCharge; + jsonData.cloudPayments.recurrent.startDate = paymentData.nextPaymentDate; } } - let amount = plan.monthlyCharge; - - const isPaymentForCurrentTariffPlan = workspace.tariffPlanId.toString() === plan._id.toString(); - - /** - * True when we need to withdraw the amount only to validate the subscription - */ - const isOnlyCardValidationNeeded = args.input.isRecurrent && isPaymentForCurrentTariffPlan && !isTariffPlanExpired; - - if (isOnlyCardValidationNeeded) { - amount = AMOUNT_FOR_CARD_VALIDATION; - } - const result = await cloudPaymentsApi.payByToken({ AccountId: user.id, - Amount: amount, + Amount: paymentData.chargeAmount, Token: token, Currency: 'RUB', JsonData: jsonData, diff --git a/src/services/promoCodeService.ts b/src/services/promoCodeService.ts new file mode 100644 index 00000000..866f0de5 --- /dev/null +++ b/src/services/promoCodeService.ts @@ -0,0 +1,387 @@ +import { Collection, Db, ObjectId } from 'mongodb'; + +const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/; +const MIN_PRICE = 1; +const RESERVATION_TTL_MS = 30 * 60 * 1000; + +interface PromoBenefit { + type: string; + percent?: number; + amount?: number; + minFinalPrice?: number; + applicablePlanIds?: ObjectId[]; +} + +interface PromoCodeDocument { + _id: ObjectId; + value: string; + benefit: PromoBenefit; + limit?: number; + expiresAt?: Date; +} + +interface PromoUsageDocument { + _id: ObjectId; + transactionId: string; + promoCodeId: ObjectId; + userId: string; + workspaceId: ObjectId; + planId: ObjectId; + benefitType: string; + originalAmount: number; + finalAmount: number; + discountAmount: number; + status: 'reserved' | 'applied'; + ordinal?: number; + reservationExpiresAt?: Date; + appliedAt?: Date; + utm?: Record; +} + +interface PromoPlan { + _id: ObjectId; + monthlyCharge: number; + isHidden?: boolean; +} + +export enum PromoCodeErrorCode { + Invalid = 'PROMO_CODE_INVALID', + LimitExceeded = 'PROMO_CODE_LIMIT_EXCEEDED', +} + +export class PromoCodeError extends Error { + constructor(public readonly code: PromoCodeErrorCode, message: string) { + super(message); + } +} + +export interface PromoQuote { + promoCodeId: ObjectId; + benefitType: 'percent_discount' | 'fixed_price'; + originalAmount: number; + finalAmount: number; + discountAmount: number; +} + +export interface PromoReservation extends PromoQuote { + created: boolean; +} + +export interface PromoCodeContext { + promoCodeService: PromoCodeService; +} + +export default class PromoCodeService { + private readonly promoCodes: Collection; + private readonly usages: Collection; + + constructor(db: Db) { + this.promoCodes = db.collection('promoCodes'); + this.usages = db.collection('promoCodeUsages'); + } + + public async quote(value: string, userId: string, workspaceId: ObjectId, plan: PromoPlan): Promise { + const normalizedValue = value.trim().toUpperCase(); + + if (!PROMO_CODE_REGEXP.test(normalizedValue)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code'); + } + + const promoCode = await this.promoCodes.findOne({ value: normalizedValue }); + + if (!promoCode) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + } + + await this.deleteExpiredReservations(promoCode._id); + await this.assertAvailable(promoCode, userId, workspaceId); + + return this.calculateQuote(promoCode, plan); + } + + public async reserve(params: { + transactionId: string; + promoCodeId: string; + userId: string; + workspaceId: ObjectId; + plan: PromoPlan; + utm?: Record; + }): Promise { + if (!ObjectId.isValid(params.promoCodeId)) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code id'); + } + + const promoCodeId = new ObjectId(params.promoCodeId); + + await this.deleteExpiredReservations(promoCodeId); + + const existingUsage = await this.usages.findOne({ transactionId: params.transactionId }); + + if (existingUsage) { + return this.toReservation(this.assertSameTransaction(existingUsage, params), false); + } + + const promoCode = await this.promoCodes.findOne({ _id: promoCodeId }); + + if (!promoCode) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found'); + } + + await this.assertAvailable(promoCode, params.userId, params.workspaceId); + + const quote = this.calculateQuote(promoCode, params.plan); + const usage = { + _id: new ObjectId(), + transactionId: params.transactionId, + promoCodeId: quote.promoCodeId, + userId: params.userId, + workspaceId: params.workspaceId, + planId: params.plan._id, + benefitType: quote.benefitType, + originalAmount: quote.originalAmount, + finalAmount: quote.finalAmount, + discountAmount: quote.discountAmount, + status: 'reserved' as const, + reservationExpiresAt: new Date(Date.now() + RESERVATION_TTL_MS), + ...(params.utm && Object.keys(params.utm).length > 0 ? { utm: params.utm } : {}), + }; + + while (true) { + let ordinal: number | undefined; + + if (typeof promoCode.limit === 'number') { + const [count, lastUsage] = await Promise.all([ + this.usages.countDocuments({ promoCodeId: promoCode._id }), + this.usages.findOne( + { + promoCodeId: promoCode._id, + ordinal: { $exists: true }, + }, + { + sort: { ordinal: -1 }, + projection: { ordinal: 1 }, + } + ), + ]); + + if (count >= promoCode.limit) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code limit exceeded'); + } + + ordinal = (lastUsage?.ordinal ?? -1) + 1; + } + + try { + await this.usages.insertOne({ + ...usage, + ...(ordinal !== undefined ? { ordinal } : {}), + }); + + return { + ...quote, + created: true, + }; + } catch (error) { + if ((error as { code?: number }).code !== 11000) { + throw error; + } + + const transactionUsage = await this.usages.findOne({ transactionId: params.transactionId }); + + if (transactionUsage) { + return this.toReservation(this.assertSameTransaction(transactionUsage, params), false); + } + + const conflictingUsage = await this.findUserOrWorkspaceUsage( + promoCode._id, + params.userId, + params.workspaceId + ); + + if (conflictingUsage || ordinal === undefined) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code was already used'); + } + } + } + } + + public async finalize(transactionId: string): Promise { + const usage = await this.usages.findOne({ transactionId }); + + if (!usage) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo reservation not found'); + } + + if (usage.status === 'applied') { + return; + } + + const result = await this.usages.updateOne( + { + _id: usage._id, + status: 'reserved', + }, + { + $set: { + status: 'applied', + appliedAt: new Date(), + }, + $unset: { reservationExpiresAt: '' }, + } + ); + + if (result.modifiedCount !== 1) { + const currentUsage = await this.usages.findOne({ transactionId }); + + if (currentUsage?.status === 'applied') { + return; + } + + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo reservation was not finalized'); + } + } + + public async release(transactionId: string): Promise { + await this.usages.deleteOne({ + transactionId, + status: 'reserved', + }); + } + + private async assertAvailable(promoCode: PromoCodeDocument, userId: string, workspaceId: ObjectId): Promise { + if (promoCode.expiresAt && promoCode.expiresAt.getTime() < Date.now()) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code expired'); + } + + if ( + promoCode.limit !== undefined && + (!Number.isInteger(promoCode.limit) || promoCode.limit <= 0) + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid promo code limit'); + } + + const [conflictingUsage, usageCount] = await Promise.all([ + this.findUserOrWorkspaceUsage(promoCode._id, userId, workspaceId), + typeof promoCode.limit === 'number' + ? this.usages.countDocuments({ promoCodeId: promoCode._id }) + : Promise.resolve(0), + ]); + + if (conflictingUsage || (typeof promoCode.limit === 'number' && usageCount >= promoCode.limit)) { + throw new PromoCodeError(PromoCodeErrorCode.LimitExceeded, 'Promo code limit exceeded'); + } + } + + private async findUserOrWorkspaceUsage( + promoCodeId: ObjectId, + userId: string, + workspaceId: ObjectId + ): Promise { + return this.usages.findOne({ + promoCodeId, + $or: [ + { userId }, + { workspaceId }, + ], + }); + } + + private async deleteExpiredReservations(promoCodeId: ObjectId): Promise { + await this.usages.deleteMany({ + promoCodeId, + status: 'reserved', + reservationExpiresAt: { $lte: new Date() }, + }); + } + + private calculateQuote(promoCode: PromoCodeDocument, plan: PromoPlan): PromoQuote { + const benefit = promoCode.benefit; + const applicablePlanIds = benefit.applicablePlanIds ?? []; + const appliesToPlan = applicablePlanIds.length === 0 || + applicablePlanIds.some(planId => planId.toString() === plan._id.toString()); + + if (plan.isHidden || plan.monthlyCharge <= 0 || !appliesToPlan) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code is not applicable to this plan'); + } + + let finalAmount: number; + + if (benefit.type === 'percent_discount') { + if ( + !Number.isFinite(benefit.percent) || + benefit.percent === undefined || + benefit.percent <= 0 || + benefit.percent > 100 || + ( + benefit.minFinalPrice !== undefined && + ( + !Number.isInteger(benefit.minFinalPrice) || + benefit.minFinalPrice < MIN_PRICE + ) + ) + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid percent discount'); + } + + const discount = Math.floor(plan.monthlyCharge * benefit.percent / 100); + + finalAmount = Math.max(plan.monthlyCharge - discount, benefit.minFinalPrice ?? MIN_PRICE); + } else if (benefit.type === 'fixed_price') { + if ( + !Number.isInteger(benefit.amount) || + benefit.amount === undefined || + benefit.amount < MIN_PRICE + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Invalid fixed price'); + } + + finalAmount = benefit.amount; + } else { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Unsupported promo code'); + } + + if (finalAmount >= plan.monthlyCharge) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code does not reduce the price'); + } + + return { + promoCodeId: promoCode._id, + benefitType: benefit.type, + originalAmount: plan.monthlyCharge, + finalAmount, + discountAmount: plan.monthlyCharge - finalAmount, + }; + } + + private assertSameTransaction( + usage: PromoUsageDocument, + params: { + promoCodeId: string; + userId: string; + workspaceId: ObjectId; + plan: PromoPlan; + } + ): PromoUsageDocument { + if ( + usage.promoCodeId.toString() !== params.promoCodeId || + usage.userId !== params.userId || + usage.workspaceId.toString() !== params.workspaceId.toString() || + usage.planId.toString() !== params.plan._id.toString() + ) { + throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Transaction has another promo reservation'); + } + + return usage; + } + + private toReservation(usage: PromoUsageDocument, created: boolean): PromoReservation { + return { + promoCodeId: usage.promoCodeId, + benefitType: usage.benefitType as PromoQuote['benefitType'], + originalAmount: usage.originalAmount, + finalAmount: usage.finalAmount, + discountAmount: usage.discountAmount, + created, + }; + } +} diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 7cf9197b..8544a684 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -235,6 +235,16 @@ input ComposePaymentInput { Whether card should be saved for future recurrent payments """ shouldSaveCard: Boolean + + """ + Promo code applied to the first payment + """ + promoCode: String + + """ + Promo attribution data + """ + promoUtm: UtmInput } """ @@ -251,6 +261,11 @@ type ComposePaymentResponse { """ plan: ComposePaymentPlanInfo! + """ + Amount to charge in the first payment + """ + chargeAmount: Int! + """ True if only card linking validation payment is expected """ diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 59601fb1..0f682f93 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,49 +1,8 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; - -export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; - -interface PlanPurchaseChecksumData { - /** - * Workspace Identifier - */ - workspaceId: string; - /** - * Id of the user making the payment - */ - userId: string; - /** - * Workspace current plan id or plan id to change - */ - tariffPlanId: string; - /** - * If true, we will save user card - */ - shouldSaveCard: boolean; - /** - * Next payment date - */ - nextPaymentDate: string; -} - -interface CardLinkChecksumData { - /** - * Workspace Identifier - */ - workspaceId: string; - /** - * Id of the user making the payment - */ - userId: string; - /** - * True if this is card linking operation โ€“ charging minimal amount of money to validate card info - */ - isCardLinkOperation: boolean; - /** - * Next payment date - */ - nextPaymentDate: string; -} +import { + PaymentChecksumData, + PaymentChecksumInput +} from '../billing/types/paymentData'; /** * Helper class for working with checksums @@ -54,9 +13,13 @@ class ChecksumService { * * @param data - data for processing billing request */ - public async generateChecksum(data: ChecksumData): Promise { + public async generateChecksum(data: PaymentChecksumInput): Promise { return jwt.sign( - data, + { + ...data, + isCardLinkOperation: Boolean(data.isCardLinkOperation), + shouldSaveCard: Boolean(data.shouldSaveCard), + }, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, { expiresIn: '30m' } ); @@ -67,25 +30,20 @@ class ChecksumService { * * @param checksum - checksum to parse */ - public parseAndVerifyChecksum(checksum: string): ChecksumData { - const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as ChecksumData; - - if ('isCardLinkOperation' in payload) { - return { - workspaceId: payload.workspaceId, - userId: payload.userId, - isCardLinkOperation: payload.isCardLinkOperation, - nextPaymentDate: payload.nextPaymentDate, - }; - } else { - return { - workspaceId: payload.workspaceId, - userId: payload.userId, - tariffPlanId: payload.tariffPlanId, - shouldSaveCard: payload.shouldSaveCard, - nextPaymentDate: payload.nextPaymentDate, - }; - } + public parseAndVerifyChecksum(checksum: string): PaymentChecksumData { + const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as PaymentChecksumData; + + return { + workspaceId: payload.workspaceId, + userId: payload.userId, + tariffPlanId: payload.tariffPlanId, + shouldSaveCard: payload.shouldSaveCard, + isCardLinkOperation: payload.isCardLinkOperation, + chargeAmount: payload.chargeAmount, + nextPaymentDate: payload.nextPaymentDate, + promoCodeId: payload.promoCodeId, + promoUtm: payload.promoUtm, + }; } } diff --git a/test/billing/cloudpayments.test.ts b/test/billing/cloudpayments.test.ts new file mode 100644 index 00000000..7165b3c3 --- /dev/null +++ b/test/billing/cloudpayments.test.ts @@ -0,0 +1,486 @@ +import '../../src/env-test'; + +const receiptApi = { + createReceipt: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock('cloudpayments', () => ({ + ClientService: jest.fn().mockImplementation(() => ({ + getReceiptApi: () => receiptApi, + getClientApi: () => ({ + cancelSubscription: jest.fn().mockResolvedValue(undefined), + }), + })), + ReceiptTypes: { Income: 'Income' }, + TaxationSystem: { SIMPLIFIED_INCOME: 'SIMPLIFIED_INCOME' }, +})); + +jest.mock('../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { + cancelPayment: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn().mockReturnValue({}), + }, + }, +})); + +jest.mock('../../src/rabbitmq', () => ({ + publish: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/utils/telegram', () => ({ + sendMessage: jest.fn().mockResolvedValue(undefined), + TelegramBotURLs: { Money: 'money' }, +})); + +jest.mock('../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@hawk.so/nodejs', () => ({ + __esModule: true, + default: { send: jest.fn() }, +})); + +import { ObjectId } from 'mongodb'; +import { + BusinessOperationStatus, + BusinessOperationType +} from '@hawk.so/types'; +import CloudPaymentsWebhooks from '../../src/billing/cloudpayments'; +import { CheckCodes, FailCodes, PayCodes } from '../../src/billing/types'; +import checksumService from '../../src/utils/checksumService'; +import sendNotification from '../../src/utils/personalNotifications'; + +process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; +process.env.CLOUDPAYMENTS_PUBLIC_ID = 'public'; +process.env.CLOUDPAYMENTS_SECRET = 'secret'; +process.env.LEGAL_ENTITY_INN = '1234567890'; + +function createPlan() { + return { + _id: new ObjectId(), + name: 'Basic', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; +} + +function createContext(options: { + promoCodeId?: string; + reserve?: jest.Mock; + finalize?: jest.Mock; + release?: jest.Mock; + existingOperation?: boolean; + operationLookup?: jest.Mock; +} = {}) { + const plan = createPlan(); + const workspace = { + _id: new ObjectId(), + name: 'Workspace', + tariffPlanId: plan._id, + subscriptionId: null, + isDebug: false, + getMemberInfo: jest.fn().mockResolvedValue({ + _id: new ObjectId(), + isAdmin: true, + }), + changePlan: jest.fn().mockResolvedValue(1), + setSubscriptionId: jest.fn().mockResolvedValue(undefined), + }; + const user = { + _id: new ObjectId(), + email: 'user@test.com', + saveNewBankCard: jest.fn().mockResolvedValue(undefined), + }; + const businessOperation = { + _id: new ObjectId(), + type: options.promoCodeId + ? BusinessOperationType.WorkspacePlanPurchase + : BusinessOperationType.CardLinkCharge, + status: BusinessOperationStatus.Pending, + setStatus: jest.fn().mockResolvedValue(undefined), + }; + const createOperation = jest.fn().mockResolvedValue(businessOperation); + const operationLookup = options.operationLookup ?? jest.fn().mockResolvedValue( + options.existingOperation ? businessOperation : null + ); + const promoCodeService = { + reserve: options.reserve ?? jest.fn().mockResolvedValue({ + created: true, + finalAmount: 750, + }), + finalize: options.finalize ?? jest.fn().mockResolvedValue(undefined), + release: options.release ?? jest.fn().mockResolvedValue(undefined), + }; + const context = { + user: { + id: user._id.toString(), + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue(workspace), + findBySubscriptionId: jest.fn().mockResolvedValue(workspace), + }, + plansFactory: { + findById: jest.fn().mockResolvedValue(plan), + }, + usersFactory: { + findById: jest.fn().mockResolvedValue(user), + }, + businessOperationsFactory: { + getBusinessOperationByTransactionId: operationLookup, + create: createOperation, + }, + }, + promoCodeService, + }; + + return { + context, + plan, + workspace, + user, + businessOperation, + createOperation, + operationLookup, + promoCodeService, + }; +} + +async function createData( + context: ReturnType, + options: { + chargeAmount?: number; + promoCodeId?: string; + isCardLinkOperation?: boolean; + } = {} +) { + const chargeAmount = options.chargeAmount ?? 1000; + const checksum = await checksumService.generateChecksum({ + workspaceId: context.workspace._id.toString(), + userId: context.user._id.toString(), + tariffPlanId: context.plan._id.toString(), + shouldSaveCard: false, + isCardLinkOperation: options.isCardLinkOperation ?? false, + chargeAmount, + nextPaymentDate: new Date('2026-10-01T00:00:00.000Z').toISOString(), + promoCodeId: options.promoCodeId, + }); + + return JSON.stringify({ + checksum, + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: context.plan.monthlyCharge, + startDate: new Date('2026-10-01T00:00:00.000Z').toISOString(), + }, + }, + }); +} + +function createBody(transactionId: number, amount: number, Data: string) { + return { + TransactionId: transactionId, + Amount: amount.toString(), + Currency: 'RUB', + DateTime: new Date(), + TestMode: true, + Status: 'Completed', + OperationType: 'Payment', + CardType: 'Visa', + CardExpDate: '12/30', + CardFirstSix: '411111', + CardLastFour: '1111', + Token: 'token', + IssuerBankCountry: 'RU', + Reason: 'DoNotHonor', + ReasonCode: 5001, + Data, + }; +} + +function createResponse() { + return { + json: jest.fn(), + }; +} + +describe('CloudPayments promo flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should trust signed payment fields and only accept recurrent settings from widget Data', async () => { + // Arrange + const setup = createContext(); + const Data = JSON.parse(await createData(setup)); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + const result = await webhooks.getDataFromRequest({ + context: setup.context, + body: { + Data: JSON.stringify({ + ...Data, + workspaceId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + chargeAmount: 1, + }), + }, + }); + + // Assert + expect(result).toMatchObject({ + workspaceId: setup.workspace._id.toString(), + tariffPlanId: setup.plan._id.toString(), + chargeAmount: 1000, + isCardLinkOperation: false, + cloudPayments: Data.cloudPayments, + }); + }); + + it('should reserve promo usage and create one pending operation', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1001, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: '1001', + promoCodeId, + })); + expect(setup.createOperation).toHaveBeenCalledWith(expect.objectContaining({ + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + })); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should keep an existing reservation and operation on /check retry', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const reserve = jest.fn().mockResolvedValue({ + created: false, + finalAmount: 750, + }); + const setup = createContext({ + promoCodeId, + reserve, + existingOperation: true, + }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1002, 750, Data), + }, res); + + // Assert + expect(setup.createOperation).not.toHaveBeenCalled(); + expect(setup.promoCodeService.release).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should not release an existing reservation when operation lookup fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const reserve = jest.fn().mockResolvedValue({ + created: false, + finalAmount: 750, + }); + const setup = createContext({ + promoCodeId, + reserve, + operationLookup: jest.fn().mockRejectedValue(new Error('mongo down')), + }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1003, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.release).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED }); + }); + + it('should reject unsigned amount changes before reserving promo usage', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1004, 1, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.WRONG_AMOUNT }); + }); + + it('should accept 1 RUB only when card-link intent is signed', async () => { + // Arrange + const setup = createContext(); + const Data = await createData(setup, { + chargeAmount: 1, + isCardLinkOperation: true, + }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.check({ + context: setup.context, + body: createBody(1005, 1, Data), + }, res); + + // Assert + expect(setup.promoCodeService.reserve).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS }); + }); + + it('should finalize promo usage before granting the plan', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ promoCodeId, existingOperation: true }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.pay({ + context: setup.context, + body: createBody(2001, 750, Data), + }, res); + + // Assert + expect(setup.promoCodeService.finalize).toHaveBeenCalledWith('2001'); + expect(setup.workspace.changePlan).toHaveBeenCalledWith(setup.plan._id); + expect(receiptApi.createReceipt).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + Items: [expect.objectContaining({ amount: 750, price: 750 })], + }) + ); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS }); + }); + + it('should request a retry when promo finalization fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ + promoCodeId, + existingOperation: true, + finalize: jest.fn().mockRejectedValue(new Error('mongo down')), + }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.pay({ + context: setup.context, + body: createBody(2002, 750, Data), + }, res); + + // Assert + expect(setup.workspace.changePlan).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: PayCodes.TEMPORARY_ERROR }); + }); + + it('should release a reservation when failed-payment checksum cannot be parsed', async () => { + // Arrange + const setup = createContext(); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3001, 750, '{ invalid json'), + }, res); + + // Assert + expect(setup.promoCodeService.release).toHaveBeenCalledWith('3001'); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); + + it('should release a reservation before loading the failed business operation', async () => { + // Arrange + const setup = createContext({ + operationLookup: jest.fn().mockRejectedValue(new Error('mongo down')), + }); + const Data = await createData(setup); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3002, 1000, Data), + }, res); + + // Assert + expect(setup.promoCodeService.release).toHaveBeenCalledWith('3002'); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); + + it('should continue failed-payment notification when reservation release fails', async () => { + // Arrange + const promoCodeId = new ObjectId().toString(); + const setup = createContext({ + promoCodeId, + existingOperation: true, + release: jest.fn().mockRejectedValue(new Error('mongo down')), + }); + const Data = await createData(setup, { chargeAmount: 750, promoCodeId }); + const res = createResponse(); + const webhooks = new CloudPaymentsWebhooks() as any; + + // Act + await webhooks.fail({ + context: setup.context, + body: createBody(3001, 750, Data), + }, res); + + // Assert + expect(setup.businessOperation.setStatus).toHaveBeenCalledWith(BusinessOperationStatus.Rejected); + expect(sendNotification).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: FailCodes.SUCCESS }); + }); +}); diff --git a/test/integration/cases/billing/check.test.ts b/test/integration/cases/billing/check.test.ts index ad199354..25ace68d 100644 --- a/test/integration/cases/billing/check.test.ts +++ b/test/integration/cases/billing/check.test.ts @@ -306,26 +306,30 @@ describe('Check webhook', () => { }); }); - test('Should allow request with amount = 1$, in case of deferred payment', async () => { + test('Should allow amount = 1 only for signed card linking', async () => { /** * Correct data */ + const nextPaymentDate = new Date().toISOString(); const data: CheckRequest = { ...mainRequest, + Amount: '1', Data: JSON.stringify({ checksum: await checksumService.generateChecksum({ workspaceId: workspace._id.toString(), userId: admin._id.toString(), tariffPlanId: planToChange._id.toString(), shouldSaveCard: false, - nextPaymentDate: new Date().toString(), + isCardLinkOperation: true, + chargeAmount: 1, + nextPaymentDate, }), cloudPayments: { recurrent: { interval: 'Month', period: 1, - startDate: new Date().toString(), - amount: 1, + startDate: nextPaymentDate, + amount: planToChange.monthlyCharge, }, }, }), diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index e1ffc6a9..483e8fde 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -1,8 +1,23 @@ import '../../src/env-test'; + +jest.mock('../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { + payByToken: jest.fn(), + }, +})); + import { ObjectId } from 'mongodb'; import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; import billingNewResolver from '../../src/resolvers/billingNew'; import { ResolverContextWithUser } from '../../src/types/graphql'; +import checksumService from '../../src/utils/checksumService'; +import cloudPaymentsApi from '../../src/utils/cloudPaymentsApi'; +import { + PromoCodeContext, + PromoCodeError, + PromoCodeErrorCode +} from '../../src/services/promoCodeService'; // Set environment variables for test process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; @@ -69,8 +84,9 @@ function createComposePaymentTestSetup(options: { const mockPlansFactory = { findById: jest.fn().mockResolvedValue(plan), }; + const quotePromoCode = jest.fn(); - const mockContext: ResolverContextWithUser = { + const mockContext = { user: { id: userId, accessTokenExpired: false, @@ -83,7 +99,10 @@ function createComposePaymentTestSetup(options: { businessOperationsFactory: {} as any, releasesFactory: {} as any, }, - }; + promoCodeService: { + quote: quotePromoCode, + } as any, + } as ResolverContextWithUser & PromoCodeContext; return { userId, @@ -94,6 +113,7 @@ function createComposePaymentTestSetup(options: { mockContext, mockWorkspacesFactory, mockPlansFactory, + quotePromoCode, }; } @@ -124,6 +144,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(false); + expect(result.chargeAmount).toBe(1000); // Check that nextPaymentDate is one month from now const oneMonthFromNow = new Date(); @@ -159,6 +180,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(true); + expect(result.chargeAmount).toBe(1); const oneMonthFromLastChargeDate = new Date(workspace.lastChargeDate); oneMonthFromLastChargeDate.setMonth(oneMonthFromLastChargeDate.getMonth() + 1); @@ -188,6 +210,7 @@ describe('GraphQLBillingNew', () => { ); expect(result.isCardLinkOperation).toBe(false); + expect(result.chargeAmount).toBe(1000); // Check that nextPaymentDate is one month from now const oneMonthFromNow = new Date(); @@ -199,5 +222,173 @@ describe('GraphQLBillingNew', () => { expect(nextPaymentDateStr).toBe(oneMonthFromNowStr); }); + + it('should return the server-calculated promo amount in the signed checksum', async () => { + // Arrange + const promoCodeId = new ObjectId(); + const { + mockContext, + planId, + workspaceId, + quotePromoCode, + } = createComposePaymentTestSetup({ + isTariffPlanExpired: true, + }); + + quotePromoCode.mockResolvedValue({ + promoCodeId, + finalAmount: 750, + }); + + // Act + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + promoCode: 'save25', + promoUtm: { source: 'test' }, + }, + }, + mockContext + ); + const checksumData = checksumService.parseAndVerifyChecksum(result.checksum); + + // Assert + expect(result.chargeAmount).toBe(750); + expect(checksumData).toMatchObject({ + tariffPlanId: planId, + isCardLinkOperation: false, + chargeAmount: 750, + promoCodeId: promoCodeId.toString(), + promoUtm: { source: 'test' }, + }); + }); + + it('should map promo validation errors to a stable client code', async () => { + // Arrange + const { + mockContext, + planId, + workspaceId, + quotePromoCode, + } = createComposePaymentTestSetup({ + isTariffPlanExpired: true, + }); + + quotePromoCode.mockRejectedValue( + new PromoCodeError(PromoCodeErrorCode.Invalid, 'Promo code not found') + ); + + // Act + const promise = billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + promoCode: 'missing', + }, + }, + mockContext + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + message: PromoCodeErrorCode.Invalid, + }); + }); + }); + + describe('payWithCard', () => { + it('should use signed first charge amount and full recurrent amount', async () => { + // Arrange + const userId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); + const planId = new ObjectId(); + const nextPaymentDate = new Date('2026-10-01T00:00:00.000Z').toISOString(); + const checksum = await checksumService.generateChecksum({ + workspaceId, + userId, + tariffPlanId: planId.toString(), + shouldSaveCard: false, + isCardLinkOperation: false, + chargeAmount: 750, + nextPaymentDate, + promoCodeId: new ObjectId().toString(), + }); + const plan: PlanDBScheme = { + _id: planId, + name: 'Test Plan', + monthlyCharge: 1000, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; + const context = { + user: { + id: userId, + accessTokenExpired: false, + }, + factories: { + workspacesFactory: { + findById: jest.fn().mockResolvedValue({ + _id: new ObjectId(workspaceId), + tariffPlanId: planId, + isDebug: false, + getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }), + isTariffPlanExpired: jest.fn().mockReturnValue(false), + }), + } as any, + plansFactory: { + findById: jest.fn().mockResolvedValue(plan), + } as any, + usersFactory: { + findById: jest.fn().mockResolvedValue({ + bankCards: [{ id: 'card-1', token: 'token-1' }], + }), + } as any, + projectsFactory: {} as any, + businessOperationsFactory: { + getBusinessOperationByTransactionId: jest.fn().mockResolvedValue({ _id: new ObjectId() }), + } as any, + releasesFactory: {} as any, + }, + } as ResolverContextWithUser; + + (cloudPaymentsApi.payByToken as jest.Mock).mockResolvedValue({ + Model: { TransactionId: 1001 }, + }); + + // Act + await billingNewResolver.Mutation.payWithCard( + undefined, + { + input: { + checksum, + cardId: 'card-1', + isRecurrent: true, + }, + }, + context + ); + + // Assert + expect(cloudPaymentsApi.payByToken).toHaveBeenCalledWith(expect.objectContaining({ + Amount: 750, + JsonData: expect.objectContaining({ + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 1000, + startDate: nextPaymentDate, + }, + }, + }), + })); + }); }); -}) +}); diff --git a/test/services/promoCodeService.test.ts b/test/services/promoCodeService.test.ts new file mode 100644 index 00000000..4333619e --- /dev/null +++ b/test/services/promoCodeService.test.ts @@ -0,0 +1,363 @@ +import { ObjectId } from 'mongodb'; +import PromoCodeService, { + PromoCodeErrorCode +} from '../../src/services/promoCodeService'; + +function createPlan(monthlyCharge = 1000) { + return { + _id: new ObjectId(), + monthlyCharge, + isHidden: false, + }; +} + +function createPromo(overrides: Record = {}) { + return { + _id: new ObjectId(), + value: 'SAVE25', + benefit: { + type: 'percent_discount', + percent: 25, + }, + ...overrides, + }; +} + +function createService(options: { + promo?: ReturnType | null; + transactionUsage?: Record | null; + conflictingUsage?: Record | null; + usageCount?: number; + insertOne?: jest.Mock; + updateOne?: jest.Mock; +} = {}) { + const promo = options.promo === undefined ? createPromo() : options.promo; + const promoCodes = { + findOne: jest.fn().mockResolvedValue(promo), + }; + const usages = { + findOne: jest.fn().mockImplementation((query) => { + if (query.transactionId) { + return Promise.resolve(options.transactionUsage ?? null); + } + + if (query.$or) { + return Promise.resolve(options.conflictingUsage ?? null); + } + + return Promise.resolve(null); + }), + countDocuments: jest.fn().mockResolvedValue(options.usageCount ?? 0), + insertOne: options.insertOne ?? jest.fn().mockResolvedValue({ insertedId: new ObjectId() }), + updateOne: options.updateOne ?? jest.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: jest.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 0 }), + }; + const db = { + collection: jest.fn((name: string) => name === 'promoCodes' ? promoCodes : usages), + }; + + return { + service: new PromoCodeService(db as any), + promo, + promoCodes, + usages, + }; +} + +describe('PromoCodeService', () => { + it('should normalize and calculate percent discount', async () => { + // Arrange + const { service, promo, promoCodes } = createService(); + const plan = createPlan(); + + // Act + const quote = await service.quote( + ' save25 ', + new ObjectId().toString(), + new ObjectId(), + plan + ); + + // Assert + expect(quote).toMatchObject({ + promoCodeId: promo?._id, + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + }); + expect(promoCodes.findOne).toHaveBeenCalledWith({ value: 'SAVE25' }); + }); + + it('should calculate fixed price for an applicable plan', async () => { + // Arrange + const plan = createPlan(); + const promo = createPromo({ + benefit: { + type: 'fixed_price', + amount: 100, + applicablePlanIds: [plan._id], + }, + }); + const { service } = createService({ promo }); + + // Act + const quote = await service.quote( + promo.value, + new ObjectId().toString(), + new ObjectId(), + plan + ); + + // Assert + expect(quote).toMatchObject({ + finalAmount: 100, + discountAmount: 900, + }); + }); + + it.each([ + { benefit: { type: 'grant_plan' } }, + { benefit: { type: 'percent_discount', percent: Number.NaN } }, + { benefit: { type: 'fixed_price', amount: Number.POSITIVE_INFINITY } }, + ])('should reject invalid benefit %#', async ({ benefit }) => { + // Arrange + const { service } = createService({ promo: createPromo({ benefit }) }); + + // Act + const promise = service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.Invalid, + }); + }); + + it('should reject an expired promo code', async () => { + // Arrange + const expired = createPromo({ expiresAt: new Date(Date.now() - 1000) }); + const { service } = createService({ promo: expired }); + + // Act + const promise = service.quote( + expired.value, + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.Invalid, + }); + }); + + it('should reject an already used promo code', async () => { + // Arrange + const used = createService({ + conflictingUsage: { _id: new ObjectId() }, + }); + + // Act + const promise = used.service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + await expect(promise).rejects.toMatchObject({ + code: PromoCodeErrorCode.LimitExceeded, + }); + }); + + it('should delete expired reservations before checking availability', async () => { + // Arrange + const { service, usages } = createService(); + + // Act + await service.quote( + 'SAVE25', + new ObjectId().toString(), + new ObjectId(), + createPlan() + ); + + // Assert + expect(usages.deleteMany).toHaveBeenCalledWith(expect.objectContaining({ + status: 'reserved', + reservationExpiresAt: { + $lte: expect.any(Date), + }, + })); + }); + + it('should create a reservation with a pricing snapshot', async () => { + // Arrange + const { service, promo, usages } = createService(); + const plan = createPlan(); + + // Act + const reservation = await service.reserve({ + transactionId: 'tx-1', + promoCodeId: promo?._id.toString() as string, + userId: 'user-1', + workspaceId: new ObjectId(), + plan, + utm: { source: 'test' }, + }); + + // Assert + expect(reservation).toMatchObject({ + created: true, + finalAmount: 750, + }); + expect(usages.insertOne).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: 'tx-1', + status: 'reserved', + finalAmount: 750, + utm: { source: 'test' }, + })); + }); + + it('should return the existing reservation for the same transaction', async () => { + // Arrange + const plan = createPlan(); + const promo = createPromo(); + const workspaceId = new ObjectId(); + const transactionUsage = { + _id: new ObjectId(), + transactionId: 'tx-2', + promoCodeId: promo._id, + userId: 'user-2', + workspaceId, + planId: plan._id, + benefitType: 'percent_discount', + originalAmount: 1000, + finalAmount: 750, + discountAmount: 250, + status: 'reserved', + }; + const { service, usages } = createService({ promo, transactionUsage }); + + // Act + const reservation = await service.reserve({ + transactionId: 'tx-2', + promoCodeId: promo._id.toString(), + userId: 'user-2', + workspaceId, + plan, + }); + + // Assert + expect(reservation).toMatchObject({ + created: false, + finalAmount: 750, + }); + expect(usages.insertOne).not.toHaveBeenCalled(); + }); + + it('should retry another ordinal after a concurrent reservation', async () => { + // Arrange + const promo = createPromo({ limit: 2 }); + const insertOne = jest.fn() + .mockRejectedValueOnce({ code: 11000 }) + .mockResolvedValueOnce({ insertedId: new ObjectId() }); + const { service, usages } = createService({ promo, insertOne }); + const plan = createPlan(); + const lastOrdinals = [-1, 0]; + + usages.countDocuments + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1); + usages.findOne.mockImplementation((query) => { + if (query.ordinal) { + const ordinal = lastOrdinals.shift(); + + return Promise.resolve(ordinal === undefined || ordinal < 0 ? null : { ordinal }); + } + + return Promise.resolve(null); + }); + + // Act + const reservation = await service.reserve({ + transactionId: 'tx-3', + promoCodeId: promo._id.toString(), + userId: 'user-3', + workspaceId: new ObjectId(), + plan, + }); + + // Assert + expect(reservation).toMatchObject({ + created: true, + }); + expect(insertOne).toHaveBeenNthCalledWith(1, expect.objectContaining({ ordinal: 0 })); + expect(insertOne).toHaveBeenNthCalledWith(2, expect.objectContaining({ ordinal: 1 })); + }); + + it('should finalize a reserved usage', async () => { + // Arrange + const reservedUsage = { + _id: new ObjectId(), + transactionId: 'tx-4', + status: 'reserved', + }; + const { service, usages } = createService({ transactionUsage: reservedUsage }); + + // Act + await service.finalize('tx-4'); + + // Assert + expect(usages.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ status: 'reserved' }), + expect.objectContaining({ + $set: expect.objectContaining({ status: 'applied' }), + $unset: { reservationExpiresAt: '' }, + }) + ); + }); + + it('should release only a reserved usage', async () => { + // Arrange + const { service, usages } = createService(); + + // Act + await service.release('tx-4'); + + // Assert + expect(usages.deleteOne).toHaveBeenCalledWith({ + transactionId: 'tx-4', + status: 'reserved', + }); + }); + + it('should finalize an applied usage idempotently', async () => { + // Arrange + const reservedUsage = { + _id: new ObjectId(), + transactionId: 'tx-4', + status: 'reserved', + }; + const applied = createService({ + transactionUsage: { + ...reservedUsage, + status: 'applied', + }, + }); + + // Act + await applied.service.finalize('tx-4'); + + // Assert + expect(applied.usages.updateOne).not.toHaveBeenCalled(); + }); +});