diff --git a/migrations/20261013000000-create-promo-code-collections.js b/migrations/20261013000000-create-promo-code-collections.js new file mode 100644 index 00000000..e4ff73e0 --- /dev/null +++ b/migrations/20261013000000-create-promo-code-collections.js @@ -0,0 +1,48 @@ +/** + * Create promo code collections and prevent repeated use by a user or workspace. + * MongoDB creates the collections when their indexes are created. + */ +module.exports = { + /** + * Create unique indexes for promo codes and successful redemptions. + * + * @param {Db} db - MongoDB database receiving the promo code indexes. + * @returns {Promise} Resolves when all indexes have been created. + */ + async up(db) { + await db.collection('promoCodes').createIndex({ + code: 1, + }, { + name: 'promoCode', + unique: true, + }); + + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + userId: 1, + }, { + name: 'promoCodeUser', + unique: true, + }); + + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { + name: 'promoCodeWorkspace', + unique: true, + }); + }, + + /** + * Remove the promo code indexes while preserving collection data. + * + * @param {Db} db - MongoDB database containing the promo code indexes. + * @returns {Promise} Resolves when all indexes have been removed. + */ + async down(db) { + await db.collection('promoCodeRedemptions').dropIndex('promoCodeWorkspace'); + await db.collection('promoCodeRedemptions').dropIndex('promoCodeUser'); + await db.collection('promoCodes').dropIndex('promoCode'); + }, +}; diff --git a/package.json b/package.json index 85f560d3..088d9a29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.0", + "version": "1.6.1", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index c24588aa..2d6d9532 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { PromoPayment, validatePromoOperation, validatePromoSubscription } from './promoCodes/redemptions'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import { @@ -22,8 +23,7 @@ import { BusinessOperationType, ConfirmedMemberDBScheme, PayloadOfWorkspacePlanPurchase, - PlanDBScheme, - PlanProlongationPayload + PlanDBScheme } from '@hawk.so/types'; import WorkspaceModel from '../models/workspace'; import HawkCatcher from '@hawk.so/nodejs'; @@ -49,6 +49,9 @@ const PENNY_MULTIPLIER = 100; * Class for describing the logic of payment routes */ export default class CloudPaymentsWebhooks { + /** + * Authenticated CloudPayments client used for subscriptions and receipts. + */ private readonly clientService = new ClientService({ publicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', privateKey: process.env.CLOUDPAYMENTS_SECRET || '', @@ -93,6 +96,7 @@ export default class CloudPaymentsWebhooks { * * @param tariffPlan - tariff plan to generate invoice id * @param workspace - workspace data to generate invoice id + * @returns human-readable invoice label for the payment widget */ private generateInvoiceId(tariffPlan: PlanDBScheme, workspace: WorkspaceModel): string { const now = new Date(); @@ -124,7 +128,9 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ + /** + * Validate the identity required for a card link or a plan purchase. + */ 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); @@ -165,7 +171,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 isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; + const expectedAmount = data.promo ? data.promo.finalAmount : plan.monthlyCharge; + const isRightAmount = +body.Amount === expectedAmount || (!data.promo && recurrentPaymentSettings?.startDate); if (!isRightAmount) { this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); @@ -173,6 +180,17 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo) { + try { + validatePromoSubscription(data, plan.monthlyCharge, Boolean(workspace.isDebug)); + await context.factories.promoCodesFactory.ensurePaymentAvailable(this.buildPromoPayment(body, data)); + } catch (error) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] ${String(error)}`, body); + + return; + } + } + /** * Create business operation about creation of subscription */ @@ -185,7 +203,7 @@ export default class CloudPaymentsWebhooks { workspaceId: workspace._id, amount: +body.Amount * PENNY_MULTIPLIER, currency: body.Currency, - userId: member._id, + userId: data.promo ? member.userId : member._id, tariffPlanId: plan._id, }, dtCreated: new Date(), @@ -226,7 +244,7 @@ export default class CloudPaymentsWebhooks { console.log('๐Ÿ’Ž CloudPayments /pay request', body); try { - data = await this.getDataFromRequest(req); + data = await this.getDataFromRequest(req, true); } catch (e) { const error = e as Error; @@ -235,7 +253,9 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ + /** + * Validate the identity required for a card link or a plan purchase. + */ 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); @@ -270,6 +290,27 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo) { + let payment: PromoPayment; + + try { + payment = this.buildPromoPayment(body, data); + validatePromoOperation(payment, businessOperation); + } catch (error) { + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Invalid promo payment: ${String(error)}`, body); + + return; + } + + try { + await req.context.factories.promoCodeRedemptionsFactory.redeem(payment); + } catch (error) { + this.sendError(res.status(500), PayCodes.RETRY, `[Billing / Pay] Could not redeem promo code: ${String(error)}`, body); + + return; + } + } + try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); @@ -303,7 +344,9 @@ export default class CloudPaymentsWebhooks { return; } - // let accountId = workspace.accountId; + /* + * let accountId = workspace.accountId; + */ /* * try { @@ -335,7 +378,9 @@ export default class CloudPaymentsWebhooks { * const error = e as Error; */ - // this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body); + /* + * this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body); + */ /* * return; @@ -361,7 +406,9 @@ export default class CloudPaymentsWebhooks { } try { - // todo: add plan-prolongation notification if it was a payment by subscription + /** + * @todo Add plan-prolongation notification for a subscription payment. + */ const senderWorkerTask: PaymentSuccessNotificationTask = { type: SenderWorkerTaskType.PaymentSuccess, payload: { @@ -442,7 +489,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.promo ? +body.Amount : tariffPlan.monthlyCharge); let messageText = ''; @@ -487,14 +534,14 @@ 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 { - data = await this.getDataFromRequest(req); + data = await this.getDataFromRequest(req, true); } catch (e) { this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request\n${failReasonLine}`, body); @@ -657,6 +704,7 @@ status: ${body.Status}` * Formats workspace identity for Telegram money notifications * * @param workspace - workspace model or null when it was not found + * @returns workspace name and identifier, or a fallback for missing data */ private formatWorkspaceForTelegram(workspace: Pick | null): string { if (!workspace) { @@ -675,6 +723,7 @@ status: ${body.Status}` * * @param req - express request * @param workspaceId - id of workspace + * @returns existing workspace model */ private async getWorkspace(req: express.Request, workspaceId: string): Promise { const workspace = await req.context.factories.workspacesFactory.findById(workspaceId); @@ -691,6 +740,7 @@ status: ${body.Status}` * * @param req - express request * @param userId - id of user to fetch + * @returns existing payer model */ private async getUser(req: express.Request, userId: string): Promise { const user = await req.context.factories.usersFactory.findById(userId); @@ -707,6 +757,7 @@ status: ${body.Status}` * * @param req - express request * @param transactionId - id of the transaction for fetching business operation + * @returns existing business operation for the provider transaction */ private async getBusinessOperation(req: express.Request, transactionId: string): Promise { const businessOperation = await req.context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId); @@ -723,6 +774,7 @@ status: ${body.Status}` * * @param userId - id of current user * @param workspace - workspace data + * @returns confirmed workspace administrator allowed to pay */ private async getMember(userId: string, workspace: WorkspaceModel): Promise { const user = await workspace.getMemberInfo(userId); @@ -747,6 +799,7 @@ status: ${body.Status}` * * @param req - express request * @param tariffPlanId - plan id + * @returns existing purchased plan model */ private async getPlan(req: express.Request, tariffPlanId: string): Promise { const plan = await req.context.factories.plansFactory.findById(tariffPlanId); @@ -779,12 +832,37 @@ status: ${body.Status}` HawkCatcher.send(new Error(errorText), backtrace); } + /** + * Map signed promo data and provider fields to a payment for domain validation. + * + * @param body - provider callback + * @param data - verified payment identity + * @returns promo payment without HTTP context + */ + private buildPromoPayment(body: CheckRequest | PayRequest, data: PaymentData): PromoPayment { + if (!data.promo || data.isCardLinkOperation) { + throw new Error('PROMO_CODE_INVALID_PAYMENT'); + } + + return { + ...data.promo, + workspaceId: data.workspaceId, + userId: data.userId, + tariffPlanId: data.tariffPlanId, + transactionId: body.TransactionId?.toString() || '', + paidAmount: Number(body.Amount), + currency: body.Currency, + }; + } + /** * Parses request body and returns data from it * * @param req - request with necessary data + * @param allowExpiredPromo - accept a delayed completion callback for a promo payment + * @returns signed payment data or identity resolved from an existing subscription */ - private async getDataFromRequest(req: express.Request): Promise { + private async getDataFromRequest(req: express.Request, allowExpiredPromo = false): Promise { const context = req.context; const body: CheckRequest | PayRequest | FailRequest = req.body; @@ -794,10 +872,17 @@ status: ${body.Status}` */ if (body.Data) { const parsedData = JSON.parse(body.Data || '{}') as WebhookData; + const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, allowExpiredPromo); + /* + * Only subscription settings come from the widget; identity and price must remain signed. + */ return { - ...checksumService.parseAndVerifyChecksum(parsedData.checksum), - ...parsedData, + tariffPlanId: '', + shouldSaveCard: false, + isCardLinkOperation: false, + ...verifiedData, + cloudPayments: parsedData.cloudPayments, }; } @@ -834,6 +919,7 @@ status: ${body.Status}` /** * Parses body and returns card data * @param request - request body to parse + * @returns reusable card details, or null when any required field is missing */ private getCardData(request: PayRequest): Omit | null { if (!request.CardType || !request.CardExpDate || !request.CardLastFour || !request.CardFirstSix || !request.Token) { @@ -855,8 +941,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 payment amount in rubles */ - 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 +952,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/promoCodes/index.ts b/src/billing/promoCodes/index.ts new file mode 100644 index 00000000..c2034821 --- /dev/null +++ b/src/billing/promoCodes/index.ts @@ -0,0 +1,103 @@ +import { ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; + +/** + * Discount applied to the current payment, in percent or RUB. + */ +export interface PromoCodeDiscount { + type: 'percent' | 'fixed'; + value: number; +} + +/** + * Promo code configuration stored in the accounts database. + */ +export interface PromoCode { + _id: ObjectId; + /** + * Uppercase code without surrounding whitespace. + */ + code: string; + discount: PromoCodeDiscount; + isDisabled?: boolean; + /** + * Soft limit on successful uses; missing or invalid values mean unlimited use. + */ + maxUses?: number; + /** + * Missing means all plans; an empty array allows none. + */ + planIds?: ObjectId[]; +} + +/** + * Normalize user input for indexed lookup. + * + * @param code - user-provided code + * @returns normalized code for indexed lookup + */ +export function normalizePromoCode(code: string): string { + if (typeof code !== 'string' || !code.trim()) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + return code.trim().toUpperCase(); +} + +/** + * Invalid or absent limits mean unlimited use; numeric zero remains a limit. + * + * @param value - manually configured limit from MongoDB + * @returns numeric limit or undefined for unlimited use + */ +export function getMaxUses(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + + return value; +} + +/** + * Calculate whole rubles, rounded up, with a 1 RUB floor. + * + * @param discount - configured percent (integer 1โ€“99) or fixed RUB discount + * @param originalAmount - regular plan price in rubles + * @returns discounted amount in whole rubles + */ +export function applyDiscount(discount: PromoCodeDiscount, originalAmount: number): number { + if (!Number.isFinite(originalAmount) || originalAmount <= 0) { + throw new UserInputError('PROMO_CODE_INVALID_AMOUNT'); + } + + if (!discount || !Number.isFinite(discount.value) || discount.value <= 0 || + (discount.type !== 'percent' && discount.type !== 'fixed') || + (discount.type === 'percent' && (!Number.isInteger(discount.value) || discount.value > 99))) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + const discountedAmount = discount.type === 'percent' + ? originalAmount * (100 - discount.value) / 100 + : originalAmount - discount.value; + + return Math.max(1, Math.ceil(discountedAmount)); +} + +/** + * Check current availability and plan restrictions without recalculating a signed price. + * + * @param promoCode - stored promo code, if found + * @param tariffPlanId - selected plan + */ +export function ensureActivePromoCode(promoCode: PromoCode | null, tariffPlanId: ObjectId): asserts promoCode is PromoCode { + if (!promoCode || promoCode.isDisabled === true) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + if (promoCode.planIds !== undefined && ( + !Array.isArray(promoCode.planIds) || + !promoCode.planIds.some(id => id instanceof ObjectId && id.equals(tariffPlanId)) + )) { + throw new UserInputError('PROMO_CODE_INVALID'); + } +} diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts new file mode 100644 index 00000000..c85826e1 --- /dev/null +++ b/src/billing/promoCodes/redemptions.ts @@ -0,0 +1,151 @@ +import { ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; +import { getMaxUses, PromoCode } from './index'; +import { BusinessOperationDBScheme, BusinessOperationType } from '@hawk.so/types'; +import type { PaymentData } from '../types/paymentData'; + +/** + * Server-calculated price snapshot used to sign a promo payment. + */ +export interface PromoCodeQuote { + promoCodeId: ObjectId; + originalAmount: number; + finalAmount: number; +} + +/** + * Successful promo use, unique per code and user or workspace. + */ +export interface PromoCodeRedemption extends PromoCodeQuote { + _id: ObjectId; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + /** + * Provider transaction identifier used to recognize callback retries. + */ + transactionId: string; +} + +/** + * User input and server-resolved payment identity for preparing a quote. + */ +export interface PromoCodeInput { + promoCode: string; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + originalAmount: number; +} + +/** + * Verified signed identity and prices combined with provider callback fields. + */ +export interface PromoPayment { + promoCodeId: string; + originalAmount: number; + finalAmount: number; + userId: string; + workspaceId: string; + tariffPlanId: string; + transactionId: string; + paidAmount: number; + currency: string; +} + +/** + * Check successful uses only, allowing retries of the same successful payment. + * + * @param promoCode - already fetched code + * @param identity - payer and paid workspace + * @param existing - successful use by this payer or workspace, if any + * @param successfulUses - number of successful uses of this code + * @param transactionId - provider transaction on callback retries + */ +export function ensurePromoCodeAvailable( + promoCode: PromoCode, + identity: Pick, + existing: PromoCodeRedemption | null, + successfulUses: number, + transactionId?: string +): void { + if (existing) { + if (transactionId && existing.transactionId === transactionId && + existing.userId.equals(identity.userId) && existing.workspaceId.equals(identity.workspaceId)) { + return; + } + + throw new UserInputError('PROMO_CODE_ALREADY_USED'); + } + + const maxUses = getMaxUses(promoCode.maxUses); + + /* + * shortcut: maxUses is a soft limit; parallel successes may exceed it, revisit only after product decides hard-cap semantics. + */ + if (maxUses !== undefined && successfulUses + 1 > maxUses) { + throw new UserInputError('PROMO_CODE_INVALID'); + } +} + +/** + * Validate the signed final amount against the provider payment, never recomputing discounts. + * + * @param payment - verified signed identity and provider callback fields + */ +export function validatePromoPayment(payment: PromoPayment): void { + const ids = [payment.promoCodeId, payment.userId, payment.workspaceId, payment.tariffPlanId]; + + if (ids.some(id => typeof id !== 'string' || !ObjectId.isValid(id)) || + typeof payment.transactionId !== 'string' || !payment.transactionId.trim() || payment.currency !== 'RUB') { + throw new UserInputError('PROMO_CODE_INVALID_PAYMENT'); + } + + if (!Number.isFinite(payment.originalAmount) || payment.originalAmount <= 0 || + !Number.isSafeInteger(payment.finalAmount) || payment.finalAmount < 1 || + payment.finalAmount > Math.max(1, Math.ceil(payment.originalAmount)) || + payment.paidAmount !== payment.finalAmount) { + throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); + } +} + +/** + * Reject callbacks that do not match the purchase accepted by /check. + * + * @param payment - verified payment + * @param operation - purchase loaded by the provider transaction ID + */ +export function validatePromoOperation(payment: PromoPayment, operation: Pick): void { + validatePromoPayment(payment); + const payload = operation.payload; + + if (operation.transactionId !== payment.transactionId || + operation.type !== BusinessOperationType.WorkspacePlanPurchase || + !('tariffPlanId' in payload) || + payload.workspaceId.toString() !== payment.workspaceId || + payload.userId.toString() !== payment.userId || + payload.tariffPlanId.toString() !== payment.tariffPlanId || + payload.amount !== payment.paidAmount * 100 || payload.currency !== payment.currency) { + throw new UserInputError('PROMO_CODE_INVALID_PAYMENT'); + } +} + +/** + * Keep future subscription charges at full price after a discounted first payment. + * + * @param data - signed payment data and widget subscription settings + * @param monthlyCharge - current full plan price + * @param isDebug - whether the workspace uses daily billing + */ +export function validatePromoSubscription(data: PaymentData, monthlyCharge: number, isDebug: boolean): void { + const recurrent = data.cloudPayments?.recurrent; + + if (recurrent && ( + recurrent.amount !== monthlyCharge || recurrent.period !== 1 || + recurrent.interval !== (isDebug ? 'Day' : 'Month') || + !data.nextPaymentDate || + Date.parse(recurrent.startDate || '') !== Date.parse(data.nextPaymentDate) + )) { + throw new UserInputError('PROMO_CODE_INVALID_SUBSCRIPTION'); + } +} diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index fb554dd9..1740ab48 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,3 +1,21 @@ +/** + * Server-signed price for a single discounted payment. + */ +export interface PromoPaymentData { + /** + * Applied promo code identifier. + */ + promoCodeId: string; + /** + * Full plan price in rubles. + */ + originalAmount: number; + /** + * Amount to charge in rubles. + */ + finalAmount: number; +} + /** * Data for setting up recurring payments */ @@ -35,6 +53,9 @@ interface CloudPaymentsSettings { recurrent: RecurrentPaymentSettings; } +/** + * Payment identity parsed from a checksum or an existing subscription. + */ export interface PaymentData { /** * Data for Cloudpayments needs @@ -60,4 +81,14 @@ export interface PaymentData { * True if this is card linking operation โ€“ charging minimal amount of money to validate card info */ isCardLinkOperation: boolean; + /** + * Price snapshot present only for promo payments. + */ + promo?: PromoPaymentData; + /** + * Subscription start date signed by composePayment for plan purchases and card linking. + * Kept outside promo because regular payments use it too; promo /check also + * verifies the widget's recurrent startDate against this signed value. + */ + nextPaymentDate?: string; } diff --git a/src/billing/types/response.ts b/src/billing/types/response.ts index f5da282c..a3373b34 100644 --- a/src/billing/types/response.ts +++ b/src/billing/types/response.ts @@ -79,6 +79,10 @@ export enum PayCodes { * Payment registered */ SUCCESS = 0, + /** + * Retry a promo callback whose successful usage could not be persisted. + */ + RETRY = 1, } /** diff --git a/src/index.ts b/src/index.ts index c60d2bfe..36b695ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import ProjectsFactory from './models/projectsFactory'; import { NonCriticalError } from './errors'; import PlansFactory from './models/plansFactory'; import BusinessOperationsFactory from './models/businessOperationsFactory'; +import PromoCodesFactory from './models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from './models/promoCodeRedemptionsFactory'; import schema from './schema'; import { graphqlUploadExpress } from 'graphql-upload'; import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics'; @@ -173,6 +175,13 @@ class HawkAPI { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const releasesFactory = new ReleasesFactory(mongo.databases.events!); + /* eslint-disable @typescript-eslint/no-non-null-assertion + */ + const promoCodeRedemptionsFactory = new PromoCodeRedemptionsFactory(mongo.databases.hawk!); + const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!, promoCodeRedemptionsFactory); + /* eslint-enable @typescript-eslint/no-non-null-assertion + */ + return { usersFactory, workspacesFactory, @@ -180,6 +189,8 @@ class HawkAPI { plansFactory, businessOperationsFactory, releasesFactory, + promoCodesFactory, + promoCodeRedemptionsFactory, }; } diff --git a/src/models/promoCodeRedemptionsFactory.ts b/src/models/promoCodeRedemptionsFactory.ts new file mode 100644 index 00000000..6c3350f4 --- /dev/null +++ b/src/models/promoCodeRedemptionsFactory.ts @@ -0,0 +1,89 @@ +import { Collection, Db, MongoServerError, ObjectId } from 'mongodb'; +import { getMaxUses, PromoCode } from '../billing/promoCodes'; +import { + ensurePromoCodeAvailable, + PromoCodeInput, + PromoCodeRedemption, + PromoPayment, + validatePromoPayment +} from '../billing/promoCodes/redemptions'; + +/** + * Successful promo usage, protected by the existing user and workspace unique indexes. + */ +export default class PromoCodeRedemptionsFactory { + private readonly collection: Collection; + + /** + * Create a successful usage factory. + * + * @param dbConnection - accounts database + */ + constructor(dbConnection: Db) { + this.collection = dbConnection.collection('promoCodeRedemptions'); + } + + /** + * Read successful usage without fetching the code again or reserving capacity. + * + * @param promoCode - already fetched code + * @param identity - payer and paid workspace + * @param transactionId - provider transaction on callback retries + */ + public async ensurePromoCodeAvailable( + promoCode: PromoCode, + identity: Pick, + transactionId?: string + ): Promise { + const existing = await this.collection.findOne({ + promoCodeId: promoCode._id, + $or: [ { userId: identity.userId }, { workspaceId: identity.workspaceId } ], + }); + const successfulUses = !existing && getMaxUses(promoCode.maxUses) !== undefined + ? await this.collection.countDocuments({ promoCodeId: promoCode._id }) + : 0; + + ensurePromoCodeAvailable(promoCode, identity, existing, successfulUses, transactionId); + } + + /** + * Record a successful provider payment; do not reject money already paid on a limit race. + * + * @param payment - verified successful payment + */ + public async redeem(payment: PromoPayment): Promise { + validatePromoPayment(payment); + + const usage: PromoCodeRedemption = { + _id: new ObjectId(), + promoCodeId: new ObjectId(payment.promoCodeId), + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + tariffPlanId: new ObjectId(payment.tariffPlanId), + transactionId: payment.transactionId, + originalAmount: payment.originalAmount, + finalAmount: payment.finalAmount, + }; + + try { + await this.collection.insertOne(usage); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) { + throw error; + } + + const existing = await this.collection.findOne({ + promoCodeId: usage.promoCodeId, + $or: [ { userId: usage.userId }, { workspaceId: usage.workspaceId } ], + }); + + if (!existing) { + throw error; + } + + if (existing.transactionId !== payment.transactionId) { + console.warn('Promo code was used by a parallel successful payment', payment.transactionId); + } + } + } +} diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts new file mode 100644 index 00000000..98a71166 --- /dev/null +++ b/src/models/promoCodesFactory.ts @@ -0,0 +1,86 @@ +import { Collection, Db, Filter, ObjectId } from 'mongodb'; +import { applyDiscount, ensureActivePromoCode, normalizePromoCode, PromoCode } from '../billing/promoCodes'; +import { PromoCodeInput, PromoCodeQuote, PromoPayment, validatePromoPayment } from '../billing/promoCodes/redemptions'; +import PromoCodeRedemptionsFactory from './promoCodeRedemptionsFactory'; + +/** + * Promo code lookup and payment availability checks. + */ +export default class PromoCodesFactory { + private readonly collection: Collection; + private readonly redemptions: PromoCodeRedemptionsFactory; + + /** + * Create a promo code factory. + * + * @param dbConnection - accounts database + * @param redemptions - successful usage factory shared with the request context + */ + constructor(dbConnection: Db, redemptions: PromoCodeRedemptionsFactory) { + this.collection = dbConnection.collection('promoCodes'); + this.redemptions = redemptions; + } + + /** + * Calculate a quote without reserving or recording usage. + * + * @param input - requested payment with server-resolved identity and price + */ + public async calculatePromoQuote(input: PromoCodeInput): Promise { + const promoCode = await this.findActivePromoCode({ code: normalizePromoCode(input.promoCode) }, input.tariffPlanId); + const finalAmount = applyDiscount(promoCode.discount, input.originalAmount); + + await this.redemptions.ensurePromoCodeAvailable(promoCode, input); + + return { + promoCodeId: promoCode._id, + originalAmount: input.originalAmount, + finalAmount, + }; + } + + /** + * Check current restrictions using a single code lookup and the signed price. + * + * @param payment - verified signed quote and provider callback fields + */ + public async ensurePaymentAvailable(payment: PromoPayment): Promise { + validatePromoPayment(payment); + + const promoCode = await this.findActivePromoCode({ _id: new ObjectId(payment.promoCodeId) }, new ObjectId(payment.tariffPlanId)); + + await this.redemptions.ensurePromoCodeAvailable(promoCode, { + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + }, payment.transactionId); + } + + /** + * Read legacy percentage documents without changing stored data. + * + * @param query - indexed code or identifier lookup + * @param tariffPlanId - selected plan + */ + private async findActivePromoCode(query: Filter, tariffPlanId: ObjectId): Promise { + const promoCode = await this.collection.findOne(query); + + ensureActivePromoCode(promoCode, tariffPlanId); + + /* + * shortcut: support discountPercent until existing documents have been migrated to discount. + */ + const legacy = promoCode as PromoCode & { discountPercent?: number }; + + if (promoCode.discount === undefined && typeof legacy.discountPercent === 'number') { + return { + ...promoCode, + discount: { + type: 'percent', + value: legacy.discountPercent, + }, + }; + } + + return promoCode; + } +} diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d5d96873..109a44ae 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -8,6 +8,8 @@ import { PayloadOfWorkspacePlanPurchase } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; +import { ObjectId } from 'mongodb'; +import type { PromoCodeQuote } from '../billing/promoCodes/redemptions'; import { UserInputError } from 'apollo-server-express'; import cloudPaymentsApi, { CloudPaymentsJsonData } from '../utils/cloudPaymentsApi'; import * as telegram from '../utils/telegram'; @@ -23,10 +25,26 @@ const AMOUNT_FOR_CARD_VALIDATION = 1; * Input data for composePayment query */ interface ComposePaymentArgs { + /** + * Workspace, selected plan and optional discount requested by the payer. + */ input: { + /** + * Workspace whose administrator is preparing the payment. + */ workspaceId: string; + /** + * Plan used for pricing and promo eligibility checks. + */ tariffPlanId: string; + /** + * Whether to retain the provider's reusable card token after payment. + */ shouldSaveCard?: boolean; + /** + * User-provided promo code; missing or blank means full-price payment. + */ + promoCode?: string | null; }; } @@ -63,6 +81,7 @@ export default { * @param ids - ids of workspaces for which transactions have been requested * @param user - current authorized user * @param factories - factories for working with models + * @returns business operations belonging to the requested workspaces */ async businessOperations( _obj: undefined, @@ -73,7 +92,13 @@ export default { }, /** - * GraphQL version of composePayment: prepares data before charge + * Prepare the server-priced payment and sign its identity and optional discount. + * + * @param _obj - parent resolver value + * @param input - workspace, plan, card-saving preference and optional promo code + * @param user - authenticated payer + * @param factories - model factories for authorization and plan lookup + * @returns widget settings, current amount in rubles and a signed payment checksum */ async composePayment( _obj: undefined, @@ -83,12 +108,14 @@ export default { invoiceId: string; plan: { id: string; name: string; monthlyCharge: number }; isCardLinkOperation: boolean; + paymentAmount: number; currency: string; checksum: string; nextPaymentDate: Date; cloudPaymentsPublicId: string; }> { const { workspaceId, tariffPlanId, shouldSaveCard } = input; + const promoCode = input.promoCode?.trim(); if (!workspaceId || !tariffPlanId || !user?.id) { throw new UserInputError('No workspaceId, tariffPlanId or user id provided'); @@ -119,13 +146,36 @@ export default { * 3. Workspace is not blocked */ if ( - workspace.tariffPlanId.toString() === tariffPlanId && // 1 - !workspace.isTariffPlanExpired() && // 2 - !workspace.isBlocked // 3 + workspace.tariffPlanId.toString() === tariffPlanId && + !workspace.isTariffPlanExpired() && + !workspace.isBlocked ) { isCardLinkOperation = true; } + let paymentAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : plan.monthlyCharge; + let promoQuote: PromoCodeQuote | undefined; + + if (promoCode) { + if (isCardLinkOperation) { + throw new UserInputError('PROMO_CODE_NOT_AVAILABLE_FOR_CARD_LINK'); + } + + if (WorkspaceModel.isPendingMember(member) || !member.isAdmin) { + throw new UserInputError('PROMO_CODE_ADMIN_REQUIRED'); + } + + promoQuote = await factories.promoCodesFactory.calculatePromoQuote({ + promoCode, + userId: new ObjectId(user.id), + workspaceId: workspace._id, + tariffPlanId: plan._id, + originalAmount: plan.monthlyCharge, + }); + + paymentAmount = promoQuote.finalAmount; + } + /** * Calculate next payment date */ @@ -151,6 +201,15 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), + ...(promoQuote + ? { + promo: { + promoCodeId: promoQuote.promoCodeId.toString(), + originalAmount: promoQuote.originalAmount, + finalAmount: promoQuote.finalAmount, + }, + } + : {}), }; const checksum = await checksumService.generateChecksum(checksumData); @@ -162,7 +221,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: ยซ${workspace.name}ยป (${workspace._id.toString()}) @@ -178,6 +237,7 @@ debug: ${Boolean(workspace.isDebug)}` monthlyCharge: plan.monthlyCharge, }, isCardLinkOperation, + paymentAmount, currency: 'RUB', checksum, nextPaymentDate, @@ -193,6 +253,7 @@ debug: ${Boolean(workspace.isDebug)}` /** * Returns type of the payload * @param payload - result from resolver above + * @returns GraphQL payload type matching the operation data */ __resolveType(payload: BusinessOperationPayloadType): string { if ('cardPan' in payload) { @@ -261,6 +322,7 @@ debug: ${Boolean(workspace.isDebug)}` * @param args - mutation args * @param user - current authorized user * @param factories - factories for working with models + * @returns business operation produced by the tokenized payment */ async payWithCard(_obj: undefined, args: PayWithCardArgs, { factories, user }: ResolverContextWithUser): Promise { const paymentData = checksumService.parseAndVerifyChecksum(args.input.checksum); @@ -269,6 +331,10 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Invalid checksum'); } + if (paymentData.promo) { + throw new UserInputError('PROMO_CODE_NOT_AVAILABLE_FOR_SAVED_CARD'); + } + const fullUserInfo = await factories.usersFactory.findById(user.id); const workspace = await factories.workspacesFactory.findById(paymentData.workspaceId); diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 7cf9197b..0cfc9814 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -235,6 +235,11 @@ input ComposePaymentInput { Whether card should be saved for future recurrent payments """ shouldSaveCard: Boolean + + """ + Optional promo code for a discount on the current plan payment + """ + promoCode: String } """ @@ -251,6 +256,11 @@ type ComposePaymentResponse { """ plan: ComposePaymentPlanInfo! + """ + Amount for the current payment in rubles, including any promo discount + """ + paymentAmount: Int! + """ True if only card linking validation payment is expected """ diff --git a/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36..aae1fd62 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 PromoCodeRedemptionsFactory from '../models/promoCodeRedemptionsFactory'; /** * Resolver's Context argument @@ -92,6 +94,16 @@ export interface ContextFactories { * Releases factory for working with releases */ releasesFactory: ReleasesFactory; + + /** + * Promo quote calculation and payment availability. + */ + promoCodesFactory: PromoCodesFactory; + + /** + * Successful promo usage and callback deduplication. + */ + promoCodeRedemptionsFactory: PromoCodeRedemptionsFactory; } /** diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 59601fb1..c809a021 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,8 +1,14 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; +import type { PromoPaymentData } from '../billing/types/paymentData'; +/** + * Signed identity for a plan purchase or a card-linking payment. + */ export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; +/** + * Plan purchase data with an optional signed promo price snapshot. + */ interface PlanPurchaseChecksumData { /** * Workspace Identifier @@ -24,8 +30,15 @@ interface PlanPurchaseChecksumData { * Next payment date */ nextPaymentDate: string; + /** + * Price snapshot present only for promo payments. + */ + promo?: PromoPaymentData; } +/** + * Signed workspace and payer identity for validating a recurring-payment card. + */ interface CardLinkChecksumData { /** * Workspace Identifier @@ -53,22 +66,29 @@ class ChecksumService { * Generates checksum for processing billing requests * * @param data - data for processing billing request + * @returns signed payment token valid for thirty minutes */ public async generateChecksum(data: ChecksumData): Promise { - return jwt.sign( - data, - process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, - { expiresIn: '30m' } - ); + return jwt.sign(data, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, { expiresIn: '30m' }); } /** * Parses checksum from request data and returns data from it * * @param checksum - checksum to parse + * @param allowExpiredPromo - allow a late callback for a checked promo payment + * @returns verified payment identity without unsigned callback fields */ - public parseAndVerifyChecksum(checksum: string): ChecksumData { - const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as ChecksumData; + public parseAndVerifyChecksum(checksum: string, allowExpiredPromo = false): ChecksumData { + const secret = process.env.JWT_SECRET_BILLING_CHECKSUM as Secret; + const payload = jwt.verify(checksum, secret, { + ignoreExpiration: allowExpiredPromo, + }) as ChecksumData; + const isPromoPayment = 'promo' in payload && Boolean(payload.promo); + + if (allowExpiredPromo && !isPromoPayment) { + jwt.verify(checksum, secret); + } if ('isCardLinkOperation' in payload) { return { @@ -84,6 +104,7 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, + ...(payload.promo ? { promo: payload.promo } : {}), }; } } diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 1db61bec..e3d7e3e2 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -158,6 +158,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -179,6 +181,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories, (req) => { @@ -200,6 +204,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -219,6 +225,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -241,6 +249,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -267,6 +277,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -301,6 +313,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -335,6 +349,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -390,6 +406,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -411,6 +429,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -434,6 +454,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -465,6 +487,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -498,6 +522,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -535,6 +561,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -573,6 +601,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -613,6 +643,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -648,6 +680,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -692,6 +726,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -750,6 +786,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -845,6 +883,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -915,6 +955,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index e1ffc6a9..a6504f44 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, + promoCodeRedemptionsFactory: {} as any, }, }; diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index ea894eb0..4f3c7f54 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, + promoCodeRedemptionsFactory: {} as any, }, }; } diff --git a/test/services/promoCodes/billingNew.test.ts b/test/services/promoCodes/billingNew.test.ts new file mode 100644 index 00000000..cd81b4db --- /dev/null +++ b/test/services/promoCodes/billingNew.test.ts @@ -0,0 +1,248 @@ +import { ObjectId } from 'mongodb'; +import jwt from 'jsonwebtoken'; +import billingNew from '../../../src/resolvers/billingNew'; +import type { ResolverContextWithUser } from '../../../src/types/graphql'; +import checksumService from '../../../src/utils/checksumService'; +import cloudPaymentsApi from '../../../src/utils/cloudPaymentsApi'; +import { databases } from '../../../src/mongo'; + +jest.mock('../../../src/metrics', () => ({ + setupMongoMetrics: jest.fn(), + withMongoMetrics: (options: unknown): unknown => options, +})); +jest.mock('../../../src/utils/telegram', () => ({ + ...jest.requireActual('../../../src/utils/telegram'), + sendMessage: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { payByToken: jest.fn() }, +})); +jest.mock('../../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn(() => { + throw new Error('Resolver must use factories, not direct MongoDB access'); + }), + }, + }, +})); + +describe('Promo payment resolver', () => { + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + const secret = 'promo-resolver-tests-only'; + + beforeAll(() => { + process.env.JWT_SECRET_BILLING_CHECKSUM = secret; + }); + + beforeEach(() => jest.clearAllMocks()); + + afterEach(() => { + expect(databases.hawk!.collection).not.toHaveBeenCalled(); + }); + + afterAll(() => { + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + }); + + function setup() { + const userId = new ObjectId(); + const plan = { + _id: new ObjectId(), + name: 'Paid plan', + monthlyCharge: 100, + }; + const workspace = { + _id: new ObjectId(), + name: 'Promo workspace', + tariffPlanId: plan._id, + lastChargeDate: new Date(), + isBlocked: false, + getMemberInfo: jest.fn().mockResolvedValue({ + userId, + isAdmin: true, + }), + isTariffPlanExpired: jest.fn().mockReturnValue(true), + }; + const quote = { + promoCodeId: new ObjectId(), + originalAmount: 100, + finalAmount: 80, + }; + const factories = { + workspacesFactory: { findById: jest.fn().mockResolvedValue(workspace) }, + plansFactory: { findById: jest.fn().mockResolvedValue(plan) }, + usersFactory: { findById: jest.fn() }, + promoCodesFactory: { calculatePromoQuote: jest.fn().mockResolvedValue(quote) }, + promoCodeRedemptionsFactory: { redeem: jest.fn() }, + businessOperationsFactory: { create: jest.fn() }, + }; + const context = { + user: { id: userId.toString() }, + factories, + } as unknown as ResolverContextWithUser; + const input = { + workspaceId: workspace._id.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: true, + promoCode: ' Promo ', + }; + + return { + userId, + workspace, + plan, + quote, + factories, + context, + input, + }; + } + + test('composePayment calls the quote factory and signs nested promo while retaining outward paymentAmount', async () => { + const { userId, workspace, plan, quote, factories, context, input } = setup(); + const result = await billingNew.Query.composePayment(undefined, { input }, context); + + expect(factories.promoCodesFactory.calculatePromoQuote).toHaveBeenCalledTimes(1); + expect(factories.promoCodesFactory.calculatePromoQuote).toHaveBeenCalledWith({ + promoCode: 'Promo', + userId, + workspaceId: workspace._id, + tariffPlanId: plan._id, + originalAmount: 100, + }); + expect(result).toMatchObject({ + paymentAmount: 80, + currency: 'RUB', + isCardLinkOperation: false, + plan: { monthlyCharge: 100 }, + }); + const signed = jwt.verify(result.checksum, secret); + const expected = { + workspaceId: workspace._id.toString(), + userId: userId.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: true, + nextPaymentDate: result.nextPaymentDate.toISOString(), + promo: { + promoCodeId: quote.promoCodeId.toString(), + originalAmount: 100, + finalAmount: 80, + }, + }; + + expect(signed).toEqual({ + ...expected, + iat: expect.any(Number), + exp: expect.any(Number), + }); + expect(checksumService.parseAndVerifyChecksum(result.checksum)).toEqual(expected); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + expect(factories.promoCodeRedemptionsFactory.redeem).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, ' '])('no promo (%s) retains ordinary full-price composition', async promoCode => { + const { factories, context, input } = setup(); + const result = await billingNew.Query.composePayment(undefined, { + input: { + ...input, + promoCode, + }, + }, context); + + expect(result).toMatchObject({ + paymentAmount: 100, + isCardLinkOperation: false, + }); + const signed = checksumService.parseAndVerifyChecksum(result.checksum); + + expect(signed).toMatchObject({ nextPaymentDate: result.nextPaymentDate.toISOString() }); + expect(signed).not.toHaveProperty('promo'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('ordinary card linking keeps the subscription date outside promo', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.isTariffPlanExpired.mockReturnValue(false); + const result = await billingNew.Query.composePayment(undefined, { + input: { + ...input, + promoCode: undefined, + }, + }, context); + const signed = checksumService.parseAndVerifyChecksum(result.checksum); + + expect(result.isCardLinkOperation).toBe(true); + expect(signed).toEqual({ + workspaceId: workspace._id.toString(), + userId: context.user.id, + isCardLinkOperation: true, + nextPaymentDate: result.nextPaymentDate.toISOString(), + }); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('card linking rejects a promo before obtaining a quote', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.isTariffPlanExpired.mockReturnValue(false); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_NOT_AVAILABLE_FOR_CARD_LINK'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('non-admin members cannot obtain a promo quote', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.getMemberInfo.mockResolvedValue({ + userId: new ObjectId(), + isAdmin: false, + }); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_ADMIN_REQUIRED'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('quote validation errors propagate without creating an operation', async () => { + const { factories, context, input } = setup(); + + factories.promoCodesFactory.calculatePromoQuote.mockRejectedValueOnce(new Error('PROMO_CODE_INVALID')); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_INVALID'); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + expect(factories.promoCodeRedemptionsFactory.redeem).not.toHaveBeenCalled(); + }); + + test.each([false, true])('payWithCard rejects a signed promo before lookup or charging (recurrent: %s)', async isRecurrent => { + const { userId, workspace, plan, quote, factories, context } = setup(); + const checksum = await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: userId.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + promo: { + promoCodeId: quote.promoCodeId.toString(), + originalAmount: 100, + finalAmount: 80, + }, + }); + + await expect(billingNew.Mutation.payWithCard(undefined, { + input: { + checksum, + cardId: 'saved-card', + isRecurrent, + }, + }, context)) + .rejects.toThrow('PROMO_CODE_NOT_AVAILABLE_FOR_SAVED_CARD'); + expect(cloudPaymentsApi.payByToken).not.toHaveBeenCalled(); + expect(factories.usersFactory.findById).not.toHaveBeenCalled(); + expect(factories.workspacesFactory.findById).not.toHaveBeenCalled(); + expect(factories.plansFactory.findById).not.toHaveBeenCalled(); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + }); +}); diff --git a/test/services/promoCodes/callbacks.test.ts b/test/services/promoCodes/callbacks.test.ts new file mode 100644 index 00000000..9f88cee9 --- /dev/null +++ b/test/services/promoCodes/callbacks.test.ts @@ -0,0 +1,775 @@ +import type { Request, Response } from 'express'; +import { Db, MongoClient, ObjectId } from 'mongodb'; +import jwt from 'jsonwebtoken'; +import HawkCatcher from '@hawk.so/nodejs'; +import { BusinessOperationStatus, BusinessOperationType } from '@hawk.so/types'; +import CloudPaymentsWebhooks from '../../../src/billing/cloudpayments'; +import { CheckCodes } from '../../../src/billing/types'; +import { ReasonCode, ReasonCodesTranscript, SubscriptionStatus } from '../../../src/billing/types/enums'; +import { databases } from '../../../src/mongo'; +import DataLoaders from '../../../src/dataLoaders'; +import WorkspacesFactory from '../../../src/models/workspacesFactory'; +import WorkspaceModel from '../../../src/models/workspace'; +import UsersFactory from '../../../src/models/usersFactory'; +import PlansFactory from '../../../src/models/plansFactory'; +import BusinessOperationsFactory from '../../../src/models/businessOperationsFactory'; +import PromoCodesFactory from '../../../src/models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from '../../../src/models/promoCodeRedemptionsFactory'; +import checksumService from '../../../src/utils/checksumService'; +import { publish } from '../../../src/rabbitmq'; +import sendNotification from '../../../src/utils/personalNotifications'; +import { sendMessage, TelegramBotURLs } from '../../../src/utils/telegram'; +import { SenderWorkerTaskType } from '../../../src/types/userNotifications'; + +/* + * Metrics starts an unrelated process-wide cleanup interval. + */ +jest.mock('../../../src/metrics', () => ({ + setupMongoMetrics: jest.fn(), + withMongoMetrics: (options: unknown): unknown => options, +})); +jest.mock('../../../src/rabbitmq', () => ({ publish: jest.fn() })); +jest.mock('../../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('../../../src/utils/telegram', () => ({ + ...jest.requireActual('../../../src/utils/telegram'), + sendMessage: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@hawk.so/nodejs', () => ({ + __esModule: true, + default: { send: jest.fn() }, +})); +jest.mock('cloudpayments', () => ({ + ...jest.requireActual('cloudpayments'), + ClientService: jest.fn().mockImplementation(() => ({ + getClientApi: (): { cancelSubscription: jest.Mock } => ({ + cancelSubscription: jest.fn().mockResolvedValue({ isSuccess: (): boolean => true }), + }), + getReceiptApi: (): { createReceipt: jest.Mock } => ({ createReceipt: jest.fn().mockResolvedValue(undefined) }), + })), +})); + +type Callback = 'check' | 'pay' | 'fail' | 'recurrent'; +/* + * Only private visibility is bypassed; handlers, models and factories are real. + */ +type TestCallbacks = Record Promise> & { + clientApi: { cancelSubscription: jest.Mock }; + receiptApi: { createReceipt: jest.Mock }; +}; + +const SECRET = 'promo-callback-tests-only'; +const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); + +describe('Payment callbacks with MongoDB', () => { + let client: MongoClient; + let db: Db; + let callbacks: TestCallbacks; + let workspaceId: ObjectId; + let userId: ObjectId; + let workspaceKeys: string[]; + let factories: { + workspacesFactory: WorkspacesFactory; + usersFactory: UsersFactory; + plansFactory: PlansFactory; + businessOperationsFactory: BusinessOperationsFactory; + promoCodesFactory: PromoCodesFactory; + promoCodeRedemptionsFactory: PromoCodeRedemptionsFactory; + }; + const previousDatabase = databases.hawk; + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + + beforeAll(async () => { + const globals = global as unknown as { __MONGO_URI__?: string }; + const uri = process.env.MONGO_URL || globals.__MONGO_URI__; + + if (!uri) { + throw new Error('Jest MongoDB test URI is not configured'); + } + client = new MongoClient(uri); + await client.connect(); + db = client.db('promo-callback-tests'); + databases.hawk = db; + process.env.JWT_SECRET_BILLING_CHECKSUM = SECRET; + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mocked(publish).mockResolvedValue(undefined); + jest.mocked(sendNotification).mockResolvedValue(undefined); + await db.dropDatabase(); + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + userId: 1, + }, { unique: true }); + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { unique: true }); + workspaceId = new ObjectId(); + userId = new ObjectId(); + const workspace = { + _id: workspaceId, + name: 'Callback workspace', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 50, + lastChargeDate: PAYMENT_DATE, + subscriptionId: 'sub-old', + }; + + workspaceKeys = Object.keys(workspace).sort(); + await db.collection('workspaces').insertOne(workspace); + await db.collection('users').insertOne({ + _id: userId, + email: 'callback@example.test', + }); + await db.collection(`team:${workspaceId}`).insertOne({ + userId, + isAdmin: true, + }); + const loaders = new DataLoaders(db); + const redemptions = new PromoCodeRedemptionsFactory(db); + + factories = { + workspacesFactory: new WorkspacesFactory(db, loaders), + usersFactory: new UsersFactory(db, loaders), + plansFactory: new PlansFactory(db, loaders), + businessOperationsFactory: new BusinessOperationsFactory(db, loaders), + promoCodesFactory: new PromoCodesFactory(db, redemptions), + promoCodeRedemptionsFactory: redemptions, + }; + callbacks = new CloudPaymentsWebhooks() as unknown as TestCallbacks; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + /* + * No lease, payment-ordering or pending-cancellation markers belong on the workspace. + */ + expect(Object.keys((await db.collection('workspaces').findOne({ _id: workspaceId }))!).sort()).toEqual(workspaceKeys); + }); + + afterAll(async () => { + databases.hawk = previousDatabase; + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + if (db) { + await db.dropDatabase(); + } + if (client) { + await client.close(); + } + }); + + async function payment(promo = true) { + const planId = new ObjectId(); + const promoCodeId = new ObjectId(); + const amount = promo ? 80 : 100; + const signedData = { + workspaceId: workspaceId.toHexString(), + userId: userId.toHexString(), + tariffPlanId: planId.toHexString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + ...(promo ? { + promo: { + promoCodeId: promoCodeId.toHexString(), + originalAmount: 100, + finalAmount: amount, + }, + } : {}), + }; + + await db.collection('plans').insertOne({ + _id: planId, + name: 'Paid plan', + monthlyCharge: 100, + }); + if (promo) { + await db.collection('promoCodes').insertOne({ + _id: promoCodeId, + code: 'PROMO', + discount: { + type: 'percent', + value: 20, + }, + }); + } + const checksum = await checksumService.generateChecksum(signedData); + const req = { + body: { + TransactionId: 1001, + Amount: amount.toString(), + Currency: 'RUB', + SubscriptionId: 'sub-new', + IssuerBankCountry: 'RU', + Reason: 'Declined', + ReasonCode: ReasonCode.DO_NOT_HONOR, + Data: JSON.stringify({ checksum }), + }, + context: { factories }, + } as unknown as Request; + + return { + planId, + promoCodeId, + signedData, + checksum, + req, + }; + } + + /* + * Isolate completion tests from /check; the end-to-end test below covers their contract. + */ + async function pending(attempt: Awaited>, date = PAYMENT_DATE) { + return factories.businessOperationsFactory.create({ + transactionId: attempt.req.body.TransactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + dtCreated: date, + payload: { + workspaceId, + userId, + tariffPlanId: attempt.planId, + amount: Number(attempt.req.body.Amount) * 100, + currency: 'RUB', + }, + }); + } + + async function invoke(method: Callback, req: Request, code = 0): Promise { + const res = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + await callbacks[method](req, res as unknown as Response); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json.mock.calls).toEqual([ [ { code } ] ]); + } + + function expectReceipt(amount: number): void { + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + email: 'callback@example.test', + Items: [ expect.objectContaining({ + amount, + price: amount, + quantity: 1, + }) ], + })); + } + + test('/check uses the usual factory and provider transaction ID without reserving promo usage', async () => { + const attempt = await payment(); + const create = jest.spyOn(factories.businessOperationsFactory, 'create'); + + await invoke('check', attempt.req); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: '1001', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: expect.objectContaining({ + workspaceId, + tariffPlanId: attempt.planId, + amount: 8000, + currency: 'RUB', + }), + })); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test('/check creates an operation that the matching /pay can complete', async () => { + const attempt = await payment(); + + await invoke('check', attempt.req); + await invoke('pay', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + payload: { userId }, + }); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expectReceipt(80); + }); + + test('same widget checksum can /check a new transaction after a failed attempt', async () => { + const attempt = await payment(); + + await invoke('check', attempt.req); + await invoke('fail', attempt.req); + attempt.req.body.TransactionId = 1002; + await invoke('check', attempt.req); + expect(JSON.parse(attempt.req.body.Data).checksum).toBe(attempt.checksum); + expect(await db.collection('businessOperations').find({}, { + projection: { + transactionId: 1, + status: 1, + _id: 0, + }, + }) + .sort({ transactionId: 1 }) + .toArray()).toEqual([ + { + transactionId: '1001', + status: BusinessOperationStatus.Rejected, + }, + { + transactionId: '1002', + status: BusinessOperationStatus.Pending, + }, + ]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test.each([ + ['amount', { Amount: '79' }, CheckCodes.WRONG_AMOUNT], + ['currency', { Currency: 'USD' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ])('/check rejects invalid promo %s', async (_name, changes, code) => { + const attempt = await payment(); + + Object.assign(attempt.req.body, changes); + await invoke('check', attempt.req, code as number); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test.each([ + ['disabled', { isDisabled: true } ], + ['another plan', { planIds: [ new ObjectId() ] } ], + ['no allowed plans', { planIds: [] } ], + ])('/check rejects a promo that is now %s', async (_name, changes) => { + const attempt = await payment(); + + await db.collection('promoCodes').updateOne({ _id: attempt.promoCodeId }, { $set: changes }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test('/check rejects a deleted promo', async () => { + const attempt = await payment(); + + await db.collection('promoCodes').deleteOne({ _id: attempt.promoCodeId }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test.each([ + ['full price', {}, 0], + ['discounted renewal', { amount: 80 }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong interval', { interval: 'Day' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong period', { period: 2 }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong start date', { startDate: '2026-12-13T12:00:00.000Z' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ])('/check validates promo recurrence: %s', async (_name, changes, code) => { + const attempt = await payment(); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + ...changes, + }, + }, + }); + await invoke('check', attempt.req, code as number); + expect(await db.collection('businessOperations').countDocuments()).toBe(code === 0 ? 1 : 0); + }); + + test('/check does not allow recurrence startDate to bypass the discounted first amount', async () => { + const attempt = await payment(); + + attempt.req.body.Amount = '1'; + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + }, + }, + }); + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test('unsigned identity, card-link and promo fields cannot override a signed promo', async () => { + const attempt = await payment(); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + isCardLinkOperation: true, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 1, + }, + }); + await invoke('check', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + type: BusinessOperationType.WorkspacePlanPurchase, + payload: { + workspaceId, + tariffPlanId: attempt.planId, + amount: 8000, + }, + }); + attempt.req.body.TransactionId = 1002; + attempt.req.body.Amount = '1'; + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + }); + + test('unsigned identity cannot override an ordinary signed purchase', async () => { + const attempt = await payment(false); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const userLookup = jest.spyOn(factories.usersFactory, 'findById'); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + isCardLinkOperation: true, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + }, + }, + }); + await invoke('check', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId, + tariffPlanId: attempt.planId, + amount: 10000, + }, + }); + await invoke('pay', attempt.req); + expect(userLookup).toHaveBeenCalledWith(userId.toString()); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', + }); + expectReceipt(100); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: workspaceId.toString(), + tariffPlanId: attempt.planId.toString(), + userId: userId.toString(), + }, + }); + expect(sendMessage).toHaveBeenCalledWith( + expect.stringContaining(`next payment date: ${attempt.signedData.nextPaymentDate}`), + TelegramBotURLs.Money + ); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test('unsigned promo cannot discount an ordinary signed purchase', async () => { + const attempt = await payment(false); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 80, + }, + }); + attempt.req.body.Amount = '80'; + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test('/pay redeems, follows the normal plan/subscription flow and reports the actual receipt amount', async () => { + const attempt = await payment(); + const operation = await pending(attempt); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const started = Date.now(); + + await invoke('pay', attempt.req); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledWith({ Id: 'sub-old' }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', + billingPeriodEventsCount: 0, + lastChargeDate: expect.any(Date), + }); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace!.lastChargeDate.getTime()).toBeGreaterThanOrEqual(started); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Confirmed }); + expect(await db.collection('promoCodeRedemptions').findOne({ promoCodeId: attempt.promoCodeId })).toMatchObject({ + userId, + workspaceId, + tariffPlanId: attempt.planId, + transactionId: '1001', + originalAmount: 100, + finalAmount: 80, + }); + expectReceipt(80); + expect(publish).toHaveBeenCalledWith('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId: workspaceId.toString(), + })); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: workspaceId.toString(), + tariffPlanId: attempt.planId.toString(), + userId: userId.toString(), + }, + }); + expect(sendMessage).toHaveBeenCalledWith(expect.stringMatching(/\[Billing \/ Pay\][\s\S]*amount: 80 RUB/), TelegramBotURLs.Money); + expect(HawkCatcher.send).not.toHaveBeenCalled(); + + /* + * Redemption is idempotent; callback side effects deliberately have no such guarantee. + */ + await invoke('pay', attempt.req); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + }); + + test.each(['before insert', 'after insert'] as const)( + '/pay retries a redemption persistence failure %s without fulfilling prematurely', async failure => { + const attempt = await payment(); + const operation = await pending(attempt); + const before = await db.collection('workspaces').findOne({ _id: workspaceId }); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const realRedeem = factories.promoCodeRedemptionsFactory.redeem.bind(factories.promoCodeRedemptionsFactory); + const redeem = jest.spyOn(factories.promoCodeRedemptionsFactory, 'redeem').mockImplementationOnce(async paymentData => { + if (failure === 'after insert') { + await realRedeem(paymentData); + } + throw new Error('Redemption database unavailable'); + }); + const res = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + await callbacks.pay(attempt.req, res as unknown as Response); + expect(res.status.mock.calls).toEqual([ [ 500 ] ]); + expect(res.json.mock.calls).toEqual([ [ { code: 1 } ] ]); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ + status: BusinessOperationStatus.Pending, + }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toEqual(before); + expect(changePlan).not.toHaveBeenCalled(); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + expect(sendNotification).not.toHaveBeenCalled(); + const persisted = await db.collection('promoCodeRedemptions').find({}) + .toArray(); + + expect(persisted).toHaveLength(failure === 'after insert' ? 1 : 0); + const errorMessage = '[Billing / Pay] Could not redeem promo code: Error: Redemption database unavailable'; + + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error(errorMessage), attempt.req.body); + expect(sendMessage).toHaveBeenCalledWith(expect.stringContaining(errorMessage), TelegramBotURLs.Money); + + await invoke('pay', attempt.req); + expect(redeem).toHaveBeenCalledTimes(2); + expect(redeem.mock.calls[1]).toEqual(redeem.mock.calls[0]); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + }); + expect(changePlan).toHaveBeenCalledTimes(1); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', + billingPeriodEventsCount: 0, + }); + expectReceipt(80); + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledTimes(1); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expect(await db.collection('promoCodeRedemptions').findOne({ transactionId: '1001' })).toMatchObject({ + promoCodeId: attempt.promoCodeId, + userId, + workspaceId, + tariffPlanId: attempt.planId, + originalAmount: 100, + finalAmount: 80, + }); + if (failure === 'after insert') { + expect(await db.collection('promoCodeRedemptions').find({}) + .toArray()).toEqual(persisted); + } + } + ); + + test.each([ + ['amount', { 'payload.amount': 7900 } ], + ['currency', { 'payload.currency': 'USD' } ], + ['workspace', { 'payload.workspaceId': new ObjectId() } ], + ['payer', { 'payload.userId': new ObjectId() } ], + ['plan', { 'payload.tariffPlanId': new ObjectId() } ], + ['operation type', { type: BusinessOperationType.CardLinkCharge } ], + ])('/pay rejects a mismatched operation %s before redeeming or changing the plan', async (_name, changes) => { + const attempt = await payment(); + const operation = await pending(attempt); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + + await db.collection('businessOperations').updateOne({ _id: operation._id }, { $set: changes }); + await invoke('pay', attempt.req); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Pending }); + expect(changePlan).not.toHaveBeenCalled(); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(HawkCatcher.send).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Invalid promo payment') }), attempt.req.body); + }); + + test('/fail rejects the operation without consumption or plan changes and retains monitoring', async () => { + const attempt = await payment(); + const operation = await pending(attempt); + const before = await db.collection('workspaces').findOne({ _id: workspaceId }); + const redeem = jest.spyOn(factories.promoCodeRedemptionsFactory, 'redeem'); + + await invoke('fail', attempt.req); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Rejected }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toEqual(before); + expect(redeem).not.toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + workspaceId: workspaceId.toString(), + reason: ReasonCodesTranscript[ReasonCode.DO_NOT_HONOR], + }, + }); + expect(sendMessage).toHaveBeenCalledWith(expect.stringMatching(/\[Billing \/ Fail\][\s\S]*amount: 80 RUB[\s\S]*transaction id: 1001/), TelegramBotURLs.Money); + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error('[Billing / Fail] Transaction failed'), attempt.req.body); + }); + + test.each(['pay', 'fail'] as const)('expired promo checksum is rejected by /check but accepted by /%s for an existing operation', async method => { + const attempt = await payment(); + const operation = await pending(attempt); + + attempt.req.body.Data = JSON.stringify({ checksum: jwt.sign(attempt.signedData, SECRET, { expiresIn: -1 }) }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + await invoke(method, attempt.req); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ + status: method === 'pay' ? BusinessOperationStatus.Confirmed : BusinessOperationStatus.Rejected, + }); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(method === 'pay' ? 1 : 0); + }); + + test('ordinary cancellation Success:false still sends the receipt and unblocks the workspace', async () => { + const attempt = await payment(false); + const operation = await pending(attempt); + + callbacks.clientApi.cancelSubscription.mockResolvedValueOnce({ + Success: false, + isSuccess: (): boolean => false, + }); + await invoke('pay', attempt.req); + expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledWith({ Id: 'sub-old' }); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Confirmed }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + subscriptionId: 'sub-new', + tariffPlanId: attempt.planId, + }); + expectReceipt(100); + expect(publish).toHaveBeenCalled(); + expect(sendNotification).toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test('ordinary payment with older dtCreated still calls changePlan and resets the billing period', async () => { + const attempt = await payment(false); + + await pending(attempt, new Date(PAYMENT_DATE.getTime() - 86400000)); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const started = Date.now(); + + await invoke('pay', attempt.req); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace).toMatchObject({ + tariffPlanId: attempt.planId, + billingPeriodEventsCount: 0, + }); + expect(workspace!.lastChargeDate.getTime()).toBeGreaterThanOrEqual(started); + expectReceipt(100); + }); + + test('a later recurring charge without widget data uses the ordinary full-price flow', async () => { + const attempt = await payment(false); + + await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { tariffPlanId: attempt.planId } }); + delete attempt.req.body.Data; + attempt.req.body.SubscriptionId = 'sub-old'; + attempt.req.body.AccountId = userId.toString(); + await invoke('check', attempt.req); + await invoke('pay', attempt.req); + expectReceipt(100); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + billingPeriodEventsCount: 0, + subscriptionId: 'sub-old', + }); + }); + + test.each([SubscriptionStatus.CANCELLED, SubscriptionStatus.REJECTED, SubscriptionStatus.ACTIVE])( + '/recurrent %s uses the normal workspace model', async status => { + const lookup = jest.spyOn(factories.workspacesFactory, 'findBySubscriptionId'); + const setSubscription = jest.spyOn(WorkspaceModel.prototype, 'setSubscriptionId'); + const req = { + body: { + Id: 'sub-old', + Status: status, + }, + context: { factories }, + } as unknown as Request; + + await invoke('recurrent', req); + expect(lookup).toHaveBeenCalledWith('sub-old'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + if (status === SubscriptionStatus.ACTIVE) { + expect(setSubscription).not.toHaveBeenCalled(); + expect(workspace!.subscriptionId).toBe('sub-old'); + } else { + expect(setSubscription).toHaveBeenCalledWith(null); + expect(workspace!.subscriptionId).toBeFalsy(); + } + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error(`[Billing / Recurrent] New recurrent event with ${status} status`), req.body); + } + ); +}); diff --git a/test/services/promoCodes/checksum.test.ts b/test/services/promoCodes/checksum.test.ts new file mode 100644 index 00000000..598380f6 --- /dev/null +++ b/test/services/promoCodes/checksum.test.ts @@ -0,0 +1,97 @@ +import jwt from 'jsonwebtoken'; +import { ObjectId } from 'mongodb'; +import checksumService from '../../../src/utils/checksumService'; + +describe('Promo payment checksums', () => { + const secret = 'promo-checksum-tests-only'; + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + const purchase = { + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + }; + const promoPurchase = { + ...purchase, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 80, + }, + }; + const cardLink = { + workspaceId: purchase.workspaceId, + userId: purchase.userId, + isCardLinkOperation: true, + nextPaymentDate: purchase.nextPaymentDate, + }; + + beforeAll(() => { + process.env.JWT_SECRET_BILLING_CHECKSUM = secret; + }); + + afterAll(() => { + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + }); + + test.each([purchase, promoPurchase, cardLink])('round-trips signed payment data without JWT metadata', async data => { + const checksum = await checksumService.generateChecksum(data); + const payload = jwt.verify(checksum, secret) as jwt.JwtPayload; + + expect(payload.exp! - payload.iat!).toBe(30 * 60); + expect(checksumService.parseAndVerifyChecksum(checksum)).toEqual(data); + }); + + test('expired promo is rejected for /check by default and allowed only for completion', () => { + const checksum = jwt.sign(promoPurchase, secret, { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum)).toThrow(jwt.TokenExpiredError); + expect(() => checksumService.parseAndVerifyChecksum(checksum, false)).toThrow(jwt.TokenExpiredError); + expect(checksumService.parseAndVerifyChecksum(checksum, true)).toEqual(promoPurchase); + }); + + test.each([purchase, cardLink, { + ...purchase, + promo: null, + } ])('completion does not accept an expired non-promo checksum', data => { + const checksum = jwt.sign(data, secret, { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum)).toThrow(jwt.TokenExpiredError); + expect(() => checksumService.parseAndVerifyChecksum(checksum, true)).toThrow(jwt.TokenExpiredError); + }); + + test.each([false, true])('nested price tampering fails signature verification (completion: %s)', async completion => { + const checksum = await checksumService.generateChecksum(promoPurchase); + const [header, encoded, signature] = checksum.split('.'); + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString()); + + payload.promo.finalAmount = 1; + const tampered = [header, Buffer.from(JSON.stringify(payload)).toString('base64url'), signature].join('.'); + + expect(() => checksumService.parseAndVerifyChecksum(tampered, completion)).toThrow('invalid signature'); + }); + + test('completion still verifies the signature of an expired promo', () => { + const checksum = jwt.sign(promoPurchase, 'wrong-secret', { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum, true)).toThrow('invalid signature'); + }); + + test('legacy top-level promo fields are not returned as signed payment data', () => { + const checksum = jwt.sign({ + ...purchase, + promoPaymentId: new ObjectId().toString(), + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + paymentAmount: 80, + discountPercent: 20, + }, secret); + + expect(checksumService.parseAndVerifyChecksum(checksum)).toEqual(purchase); + }); +}); diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts new file mode 100644 index 00000000..4bc1e7f3 --- /dev/null +++ b/test/services/promoCodes/index.test.ts @@ -0,0 +1,143 @@ +import { ObjectId } from 'mongodb'; +import { + applyDiscount, + ensureActivePromoCode, + getMaxUses, + normalizePromoCode, + PromoCode, + PromoCodeDiscount +} from '../../../src/billing/promoCodes'; + +describe('Promo code normalization', () => { + test('should trim whitespace and normalize case', () => { + expect(normalizePromoCode(' sale20 ')).toBe('SALE20'); + }); + + test.each(['', ' ', null, undefined, 20])('should reject invalid input: %j', code => { + expect(() => normalizePromoCode(code as string)).toThrow('PROMO_CODE_INVALID'); + }); +}); + +describe('Promo code usage limit', () => { + test.each([0, 1, 10])('should preserve numeric limit %j', value => { + expect(getMaxUses(value)).toBe(value); + }); + + test.each([undefined, null, -1, '10', '0', 'invalid', NaN, Infinity, false])( + 'should treat %j as unlimited without converting strings', value => { + expect(getMaxUses(value)).toBeUndefined(); + } + ); +}); + +describe('Discounted payment amount', () => { + test.each([ + [100, 20, 80], + [101, 20, 81], + [20, 99, 1], + [1, 50, 1], + [100, 99, 1], + [200, 99, 2], + [100, 1, 99], + [100, 71, 29], + ])('should discount %j RUB by %j percent to %j RUB', (amount, percent, expected) => { + expect(applyDiscount({ + type: 'percent', + value: percent, + }, amount)).toBe(expected); + }); + + test.each([ + [100, 20, 80], + [100, 19.5, 81], + [100.5, 20, 81], + [100, 100, 1], + [100, 200, 1], + [0.5, 1, 1], + ])('should discount %j RUB by a fixed %j RUB to %j RUB', (amount, value, expected) => { + expect(applyDiscount({ + type: 'fixed', + value, + }, amount)).toBe(expected); + }); + + test.each([0, -1, NaN, Infinity])('should reject invalid amount %j', amount => { + expect(() => applyDiscount({ + type: 'percent', + value: 20, + }, amount)).toThrow('PROMO_CODE_INVALID_AMOUNT'); + }); + + test.each([0, -1, 100, 29.1, NaN, Infinity])('should reject invalid percent %j', value => { + expect(() => applyDiscount({ + type: 'percent', + value, + }, 100)).toThrow('PROMO_CODE_INVALID'); + }); + + test.each([0, -1, NaN, Infinity])('should reject invalid fixed discount %j', value => { + expect(() => applyDiscount({ + type: 'fixed', + value, + }, 100)).toThrow('PROMO_CODE_INVALID'); + }); + + test.each([undefined, null, {}, { + type: 'other', + value: 20, + }, { + type: 'percent', + value: '20', + } ])( + 'should reject malformed discount %j', discount => { + expect(() => applyDiscount(discount as PromoCodeDiscount, 100)).toThrow('PROMO_CODE_INVALID'); + } + ); +}); + +describe('Active promo code validation', () => { + let promoCode: PromoCode; + let planId: ObjectId; + + beforeEach(() => { + planId = new ObjectId(); + promoCode = { + _id: new ObjectId(), + code: 'SALE20', + discount: { + type: 'percent', + value: 20, + }, + }; + }); + + test('should allow any plan when planIds is absent', () => { + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); + }); + + test('should reject a code that does not exist', () => { + expect(() => ensureActivePromoCode(null, planId)).toThrow('PROMO_CODE_INVALID'); + }); + + test('should reject a disabled code', () => { + promoCode.isDisabled = true; + expect(() => ensureActivePromoCode(promoCode, planId)).toThrow('PROMO_CODE_INVALID'); + }); + + test.each([undefined, false, null, 0, '', 'true'])('should allow isDisabled %j', isDisabled => { + promoCode.isDisabled = isDisabled as boolean; + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); + }); + + test('should allow a plan explicitly listed in planIds', () => { + promoCode.planIds = [new ObjectId(), planId]; + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); + }); + + test.each([ [], [ new ObjectId() ], null, 'all', [ null ], [ 'not-a-plan-id' ] ])( + 'should reject ineligible or malformed planIds %j', planIds => { + promoCode.planIds = planIds as ObjectId[]; + expect(() => ensureActivePromoCode(promoCode, planId)).toThrow('PROMO_CODE_INVALID'); + } + ); +}); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts new file mode 100644 index 00000000..5d7641b9 --- /dev/null +++ b/test/services/promoCodes/redemptions.test.ts @@ -0,0 +1,473 @@ +import type { Db } from 'mongodb'; +import { MongoClient, MongoServerError, ObjectId } from 'mongodb'; +import { validatePromoPayment } from '../../../src/billing/promoCodes/redemptions'; +import PromoCodesFactory from '../../../src/models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from '../../../src/models/promoCodeRedemptionsFactory'; +import type { PromoCodeInput, PromoPayment, PromoCodeRedemption } from '../../../src/billing/promoCodes/redemptions'; +import type { PromoCode } from '../../../src/billing/promoCodes'; +import migration from '../../../migrations/20261013000000-create-promo-code-collections'; + +describe('Promo code factories and successful usage', () => { + const findPromoCode = jest.fn(); + const findRedemption = jest.fn(); + const countDocuments = jest.fn(); + const insertOne = jest.fn(); + const collection = jest.fn(); + const db = { collection } as unknown as Db; + let promoCode: PromoCode; + let input: PromoCodeInput; + let payment: PromoPayment; + let existingUse: PromoCodeRedemption; + let promoCodesFactory: PromoCodesFactory; + let redemptionsFactory: PromoCodeRedemptionsFactory; + + beforeEach(() => { + jest.resetAllMocks(); + promoCode = { + _id: new ObjectId(), + code: 'SALE20', + discount: { + type: 'percent', + value: 20, + }, + }; + input = { + promoCode: ' sale20 ', + userId: new ObjectId(), + workspaceId: new ObjectId(), + tariffPlanId: new ObjectId(), + originalAmount: 100, + }; + payment = { + promoCodeId: promoCode._id.toString(), + userId: input.userId.toString(), + workspaceId: input.workspaceId.toString(), + tariffPlanId: input.tariffPlanId.toString(), + originalAmount: 100, + finalAmount: 80, + transactionId: 'payment-123', + paidAmount: 80, + currency: 'RUB', + }; + existingUse = { + _id: new ObjectId(), + promoCodeId: promoCode._id, + userId: input.userId, + workspaceId: input.workspaceId, + tariffPlanId: input.tariffPlanId, + transactionId: payment.transactionId, + originalAmount: 100, + finalAmount: 80, + }; + collection.mockImplementation((name: string) => { + if (name === 'promoCodes') { + return { findOne: findPromoCode }; + } + + return { + findOne: findRedemption, + countDocuments, + insertOne, + }; + }); + findPromoCode.mockResolvedValue(promoCode); + findRedemption.mockResolvedValue(null); + countDocuments.mockResolvedValue(0); + insertOne.mockResolvedValue({ acknowledged: true }); + redemptionsFactory = new PromoCodeRedemptionsFactory(db); + promoCodesFactory = new PromoCodesFactory(db, redemptionsFactory); + }); + + test('should calculate a quote with one normalized lookup and no usage writes', async () => { + expect(await promoCodesFactory.calculatePromoQuote(input)).toEqual({ + promoCodeId: promoCode._id, + originalAmount: 100, + finalAmount: 80, + }); + expect(collection).toHaveBeenCalledWith('promoCodes'); + expect(collection).toHaveBeenCalledWith('promoCodeRedemptions'); + expect(findPromoCode).toHaveBeenCalledTimes(1); + expect(findPromoCode).toHaveBeenCalledWith({ code: 'SALE20' }); + expect(findRedemption).toHaveBeenCalledWith({ + promoCodeId: promoCode._id, + $or: [ { userId: input.userId }, { workspaceId: input.workspaceId } ], + }); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should calculate fixed discounts', async () => { + promoCode.discount = { + type: 'fixed', + value: 25.5, + }; + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 75 }); + }); + + test('should read legacy discountPercent without modifying the document', async () => { + const legacy = { + ...promoCode, + discount: undefined, + discountPercent: 20, + }; + + findPromoCode.mockResolvedValue(legacy); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); + expect(legacy.discount).toBeUndefined(); + }); + + test('should prefer the new discount over legacy discountPercent', async () => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discountPercent: 50, + }); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); + }); + + test.each([null, { + type: 'percent', + value: 29.1, + }, { + type: 'fixed', + value: -1, + } ])( + 'should not fall back to a legacy value for invalid new discount %j', async discount => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discount, + discountPercent: 20, + }); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + } + ); + + test.each([undefined, '20', 29.1])('should reject missing or invalid legacy discount %j', async discountPercent => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discount: undefined, + discountPercent, + }); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test('should allow repeated quotes while no successful use exists', async () => { + const first = await promoCodesFactory.calculatePromoQuote(input); + + expect(await promoCodesFactory.calculatePromoQuote(input)).toEqual(first); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test.each(['userId', 'workspaceId'] as const)('should reject reuse by the same %s', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + + existingUse[otherField] = new ObjectId(); + findRedemption.mockResolvedValue(existingUse); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test.each([0, 1])('should reject an exhausted limit of %j', async maxUses => { + promoCode.maxUses = maxUses; + countDocuments.mockResolvedValue(maxUses); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_INVALID'); + expect(countDocuments).toHaveBeenCalledWith({ promoCodeId: promoCode._id }); + }); + + test.each([undefined, -1, '2'])('should ignore invalid or missing limit %j', async maxUses => { + findPromoCode.mockResolvedValue({ + ...promoCode, + maxUses, + }); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test.each(['missing', 'disabled', 'wrong-plan'])('should reject %s codes for quotes and checks', async reason => { + if (reason === 'missing') { + findPromoCode.mockResolvedValue(null); + } else if (reason === 'disabled') { + promoCode.isDisabled = true; + } else { + promoCode.planIds = [ new ObjectId() ]; + } + + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_INVALID'); + expect(findRedemption).not.toHaveBeenCalled(); + }); + + test('should check payment availability with one ID lookup and without recomputing discount', async () => { + promoCode.discount = { + type: 'fixed', + value: 90, + }; + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + expect(findPromoCode).toHaveBeenCalledTimes(1); + expect(findPromoCode).toHaveBeenCalledWith({ _id: promoCode._id }); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should allow the same successful payment retry even at the usage limit', async () => { + promoCode.maxUses = 1; + findRedemption.mockResolvedValue(existingUse); + countDocuments.mockResolvedValue(1); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test('should reject a different transaction using the same identities', async () => { + existingUse.transactionId = 'another-payment'; + findRedemption.mockResolvedValue(existingUse); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + }); + + test('maxUses is soft: parallel checked payments may exceed it and both successes must be recorded', async () => { + promoCode.maxUses = 1; + const otherPayment = { + ...payment, + userId: new ObjectId().toString(), + workspaceId: new ObjectId().toString(), + transactionId: 'parallel-payment', + }; + + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + countDocuments.mockResolvedValue(1); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(insertOne).toHaveBeenCalledTimes(2); + expect(countDocuments).toHaveBeenCalledTimes(2); + expect(findPromoCode).toHaveBeenCalledTimes(2); + }); + + test('should record only successful usage without status, expiry, discount or attempt fields', async () => { + await redemptionsFactory.redeem(payment); + expect(insertOne).toHaveBeenCalledWith({ + ...existingUse, + _id: expect.any(ObjectId), + }); + expect(findPromoCode).not.toHaveBeenCalled(); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test('should tolerate the unique-index conflict on a successful callback retry', async () => { + findRedemption.mockResolvedValue(existingUse); + insertOne.mockRejectedValue(new MongoServerError({ + code: 11000, + message: 'Duplicate key', + })); + await expect(redemptionsFactory.redeem(payment)).resolves.toBeUndefined(); + expect(findRedemption).toHaveBeenCalledWith({ + promoCodeId: promoCode._id, + $or: [ { userId: input.userId }, { workspaceId: input.workspaceId } ], + }); + }); + + test.each(['userId', 'workspaceId'] as const)('should not block fulfillment of a parallel success sharing %s', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + + existingUse[otherField] = new ObjectId(); + existingUse.transactionId = 'another-payment'; + findRedemption.mockResolvedValue(existingUse); + insertOne.mockRejectedValue(new MongoServerError({ + code: 11000, + message: 'Duplicate key', + })); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await expect(redemptionsFactory.redeem(payment)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith('Promo code was used by a parallel successful payment', payment.transactionId); + } finally { + warn.mockRestore(); + } + }); + + test.each([ + new Error('Database unavailable'), + new MongoServerError({ + code: 11000, + message: 'Unrelated duplicate key', + }), + new MongoServerError({ + code: 123, + message: 'Other MongoDB failure', + }), + ])('should propagate a database failure without a matching usage: %s', async error => { + insertOne.mockRejectedValue(error); + await expect(redemptionsFactory.redeem(payment)).rejects.toBe(error); + }); + + test('should accept the signed snapshot without needing discount data or an attempt ID', () => { + payment.finalAmount = 37; + payment.paidAmount = 37; + expect(() => validatePromoPayment(payment)).not.toThrow(); + }); + + test('should allow the rounded one-ruble floor for a sub-ruble original price', () => { + payment.originalAmount = 0.5; + payment.finalAmount = 1; + payment.paidAmount = 1; + expect(() => validatePromoPayment(payment)).not.toThrow(); + }); + + test.each([1, 81, NaN, Infinity, '80'])('should reject changed paid amount %j before DB access', async paidAmount => { + payment.paidAmount = paidAmount as number; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); + await expect(redemptionsFactory.redeem(payment)).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); + expect(findPromoCode).not.toHaveBeenCalled(); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test.each([0, -1, 80.5, 101, NaN, Infinity])('should reject invalid signed final amount %j even if paid', finalAmount => { + payment.finalAmount = finalAmount; + payment.paidAmount = finalAmount; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + }); + + test.each([0, -1, NaN, Infinity, '100'])('should reject invalid original amount %j', originalAmount => { + payment.originalAmount = originalAmount as number; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + }); + + test.each(['promoCodeId', 'userId', 'workspaceId', 'tariffPlanId'] as const)('should reject invalid %s', field => { + payment[field] = 'not-an-object-id'; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); + + test.each(['', ' ', undefined, 123])('should reject invalid transaction ID %j', transactionId => { + payment.transactionId = transactionId as string; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); + + test.each(['USD', 'rub', '', undefined])('should reject currency %j', currency => { + payment.currency = currency as string; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); +}); + +describe('Promo factories with MongoDB unique indexes', () => { + let client: MongoClient; + let db: Db; + let promoCodesFactory: PromoCodesFactory; + let redemptionsFactory: PromoCodeRedemptionsFactory; + let payment: PromoPayment; + + beforeAll(async () => { + const globals = global as unknown as { __MONGO_URI__?: string }; + const uri = process.env.MONGO_URL || globals.__MONGO_URI__; + + if (!uri) { + throw new Error('Jest MongoDB test URI is not configured'); + } + + client = new MongoClient(uri); + await client.connect(); + db = client.db('promo-factory-tests-' + new ObjectId().toHexString()); + await migration.up(db); + redemptionsFactory = new PromoCodeRedemptionsFactory(db); + promoCodesFactory = new PromoCodesFactory(db, redemptionsFactory); + }); + + beforeEach(async () => { + await db.collection('promoCodes').deleteMany({}); + await db.collection('promoCodeRedemptions').deleteMany({}); + const promoCodeId = new ObjectId(); + + await db.collection('promoCodes').insertOne({ + _id: promoCodeId, + code: 'SALE20', + discount: { + type: 'percent', + value: 20, + }, + maxUses: 1, + }); + payment = { + promoCodeId: promoCodeId.toHexString(), + originalAmount: 100, + finalAmount: 80, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + tariffPlanId: new ObjectId().toHexString(), + transactionId: 'successful-payment', + paidAmount: 80, + currency: 'RUB', + }; + }); + + afterAll(async () => { + try { + await db?.dropDatabase(); + } finally { + await client?.close(); + } + }); + + test('should enforce uniqueness of code while allowing different codes', async () => { + const promoCodes = db.collection('promoCodes'); + + await promoCodes.insertOne({ code: 'OTHER' }); + await expect(promoCodes.insertOne({ code: 'SALE20' })).rejects.toMatchObject({ code: 11000 }); + await expect(promoCodesFactory.calculatePromoQuote({ + promoCode: ' sale20 ', + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + tariffPlanId: new ObjectId(payment.tariffPlanId), + originalAmount: 100, + })).resolves.toMatchObject({ finalAmount: 80 }); + }); + + test('concurrent callback retries should persist exactly one usage', async () => { + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(payment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + }); + + test.each(['userId', 'workspaceId'] as const)('parallel successes sharing %s should keep one usage without rejecting fulfillment', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + const otherPayment = { + ...payment, + [otherField]: new ObjectId().toHexString(), + transactionId: 'parallel-payment', + }; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + test('parallel successes with distinct identities may exceed maxUses, then block new payments', async () => { + const otherPayment = { + ...payment, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + transactionId: 'parallel-payment', + }; + + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(2); + await expect(promoCodesFactory.ensurePaymentAvailable({ + ...payment, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + transactionId: 'later-payment', + })).rejects.toThrow('PROMO_CODE_INVALID'); + }); +}); diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 307c9777..e8f6106c 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, + promoCodeRedemptionsFactory: {} as any, }; /**