diff --git a/migrations/20261013000000-create-promo-code-collections.js b/migrations/20261013000000-create-promo-code-collections.js new file mode 100644 index 00000000..b4d7df2c --- /dev/null +++ b/migrations/20261013000000-create-promo-code-collections.js @@ -0,0 +1,36 @@ +/** + * Create promo code collections and prevent repeated use by a user or workspace. + * MongoDB creates the collections when their indexes are created. + */ +module.exports = { + async up(db) { + await db.collection('promoCodes').createIndex({ + value: 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, + }); + }, + + 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 2319788e..85f560d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.16", + "version": "1.6.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index c24588aa..aedf0bb1 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -1,4 +1,9 @@ import express from 'express'; +import { ObjectId, Db } from 'mongodb'; +import { databases } from '../mongo'; +import { checkPromoCodeUsage, recordPromoCodeUsage, validatePromoPayment, PromoPayment } from './promoCodes/redemptions'; +import { findActivePromoCode } from './promoCodes'; +import { applyPromoPlan } from './promoCodes/application'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import { @@ -22,8 +27,7 @@ import { BusinessOperationType, ConfirmedMemberDBScheme, PayloadOfWorkspacePlanPurchase, - PlanDBScheme, - PlanProlongationPayload + PlanDBScheme } from '@hawk.so/types'; import WorkspaceModel from '../models/workspace'; import HawkCatcher from '@hawk.so/nodejs'; @@ -159,6 +163,12 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promoPaymentId) { + await this.checkPromoPayment(req, res, data, workspace, plan); + + return; + } + const recurrentPaymentSettings = data.cloudPayments?.recurrent; /** @@ -250,6 +260,12 @@ export default class CloudPaymentsWebhooks { } } + if (data.promoPaymentId) { + await this.finishPromoPayment(req, res, data, true); + + return; + } + let businessOperation; let workspace; let tariffPlan; @@ -487,7 +503,7 @@ 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); @@ -501,6 +517,12 @@ subscription id: ${body.SubscriptionId}`; return; } + if (data.promoPaymentId) { + await this.finishPromoPayment(req, res, data, false); + + return; + } + let businessOperation; let workspace; let user; @@ -779,6 +801,374 @@ status: ${body.Status}` HawkCatcher.send(new Error(errorText), backtrace); } + /** + * Require the accounts database for promo payments. + * + * @returns accounts database for promo payments + */ + private getPromoDatabase(): Db { + if (!databases.hawk) { + throw new Error('Accounts database is not connected'); + } + + return databases.hawk; + } + + /** + * Build the payment identity from verified checksum fields and the callback. + * + * @param body - provider callback + * @param data - parsed payment data + * @returns signed payment details + */ + private getPromoPayment(body: CheckRequest | PayRequest | FailRequest, data: PaymentData): PromoPayment { + if (!data.promoPaymentId || !data.promoCodeId || typeof data.paymentAmount !== 'number' || + typeof data.originalAmount !== 'number' || typeof data.discountPercent !== 'number' || data.isCardLinkOperation) { + throw new Error('PROMO_CODE_INVALID_PAYMENT'); + } + + return { + promoPaymentId: data.promoPaymentId, + promoCodeId: data.promoCodeId, + originalAmount: data.originalAmount, + discountPercent: data.discountPercent, + workspaceId: data.workspaceId, + userId: data.userId, + tariffPlanId: data.tariffPlanId, + paymentAmount: data.paymentAmount, + transactionId: body.TransactionId?.toString() || '', + amount: Number(body.Amount), + currency: body.Currency, + }; + } + + /** + * Check usage and create one pending operation without consuming the code. + * + * @param req - callback request + * @param res - provider response + * @param data - verified payment data + * @param workspace - authorized workspace + * @param plan - selected plan + */ + private async checkPromoPayment( + req: express.Request, + res: express.Response, + data: PaymentData, + workspace: WorkspaceModel, + plan: PlanDBScheme + ): Promise { + try { + const db = this.getPromoDatabase(); + const payment = this.getPromoPayment(req.body, data); + const recurrent = data.cloudPayments?.recurrent; + + if (recurrent && ( + recurrent.amount !== plan.monthlyCharge || + recurrent.period !== 1 || + recurrent.interval !== (workspace.isDebug ? 'Day' : 'Month') || + !data.nextPaymentDate || + Date.parse(recurrent.startDate || '') !== Date.parse(data.nextPaymentDate) + )) { + throw new Error('PROMO_CODE_INVALID_SUBSCRIPTION'); + } + + validatePromoPayment(payment); + const promoCode = await db.collection('promoCodes').findOne({ _id: new ObjectId(payment.promoCodeId) }); + + if (!promoCode) { + throw new Error('PROMO_CODE_INVALID'); + } + + await findActivePromoCode(db, promoCode.value, plan._id); + await checkPromoCodeUsage(db, promoCode._id, new ObjectId(data.userId), workspace._id, payment.transactionId); + + await db.collection('businessOperations').updateOne({ + _id: new ObjectId(payment.promoPaymentId), + transactionId: payment.transactionId, + }, { + $setOnInsert: { + transactionId: payment.transactionId, + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId: workspace._id, + userId: new ObjectId(data.userId), + tariffPlanId: plan._id, + amount: payment.amount * PENNY_MULTIPLIER, + currency: payment.currency, + }, + dtCreated: new Date(), + }, + }, { + upsert: true, + }); + + + res.json({ code: CheckCodes.SUCCESS }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = message === 'PROMO_CODE_WRONG_AMOUNT' + ? CheckCodes.WRONG_AMOUNT + : CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED; + + this.sendError(res, code, `[Billing / Check] ${message}`, req.body); + } + } + + /** + * Require the operation created for this signed payment attempt. + * + * @param req - callback request + * @param payment - validated promo payment + * @returns matching business operation + */ + private async getPromoBusinessOperation(req: express.Request, payment: PromoPayment): Promise { + const operation = await this.getBusinessOperation(req, payment.transactionId); + const payload = operation.payload; + + if (!operation._id?.equals(new ObjectId(payment.promoPaymentId)) || + 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.amount * PENNY_MULTIPLIER || payload.currency !== payment.currency) { + throw new Error('PROMO_CODE_INVALID_PAYMENT'); + } + + return operation; + } + + /** + * Finish only a checked promo transaction. A short lease serializes duplicate + * callbacks; failed handlers release it, and crashed handlers can be retried. + * + * @param req - provider callback + * @param res - provider response + * @param data - verified payment data + * @param succeeded - whether the provider reported a successful payment + */ + private async finishPromoPayment( + req: express.Request, + res: express.Response, + data: PaymentData, + succeeded: boolean + ): Promise { + const token = new ObjectId(); + let operationId: ObjectId | undefined; + + try { + const db = this.getPromoDatabase(); + const payment = this.getPromoPayment(req.body, data); + + validatePromoPayment(payment); + + let operation = await this.getPromoBusinessOperation(req, payment); + const now = new Date(); + const lock = await db.collection('businessOperations').updateOne({ + _id: operation._id, + $or: [ + { promoProcessingUntil: { $exists: false } }, + { promoProcessingUntil: { $lte: now } }, + ], + }, { + $set: { + promoProcessingToken: token, + promoProcessingUntil: new Date(now.getTime() + 5 * 60 * 1000), + }, + }); + + if (lock.modifiedCount !== 1) { + throw new Error('PROMO_PAYMENT_IN_PROGRESS'); + } + + operationId = new ObjectId(payment.promoPaymentId); + operation = await this.getPromoBusinessOperation(req, payment); + + if (!succeeded && operation.status === BusinessOperationStatus.Confirmed) { + res.json({ code: 0 }); + + return; + } + + if (succeeded && operation.status === BusinessOperationStatus.Rejected) { + throw new Error('PROMO_PAYMENT_ALREADY_REJECTED'); + } + + const delivery = await db.collection('businessOperations').findOne({ _id: operationId }); + const isFinished = succeeded + ? operation.status === BusinessOperationStatus.Confirmed && delivery?.promoReceiptSent && delivery?.promoNotificationSent + : operation.status === BusinessOperationStatus.Rejected && delivery?.promoNotificationSent; + + if (isFinished) { + res.json({ code: 0 }); + + return; + } + + const workspace = await this.getWorkspace(req, data.workspaceId); + const user = await this.getUser(req, data.userId); + + if (succeeded) { + const plan = await this.getPlan(req, data.tariffPlanId); + + /** + * A successful provider payment consumes the code even if applying the + * workspace changes fails. The pending operation remains retryable. + */ + if (operation.status !== BusinessOperationStatus.Confirmed) { + await this.updatePromoOperation(db, operationId, token); + await recordPromoCodeUsage(db, payment); + await this.updatePromoOperation(db, operationId, token); + + const ownsBillingPeriod = await applyPromoPlan(db, workspace._id, plan._id, operationId, operation.dtCreated); + + if (ownsBillingPeriod) { + const subscriptionId = req.body.SubscriptionId; + + await this.updatePromoOperation(db, operationId, token); + + if (workspace.subscriptionId && workspace.subscriptionId !== subscriptionId) { + await this.clientApi.cancelSubscription({ Id: workspace.subscriptionId }); + } + + await this.updatePromoOperation(db, operationId, token); + + if (subscriptionId) { + await workspace.setSubscriptionId(subscriptionId); + } + + await this.updatePromoOperation(db, operationId, token); + await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId: data.workspaceId, + })); + } + + await this.updatePromoOperation(db, operationId, token, { status: BusinessOperationStatus.Confirmed }); + } + + if (data.shouldSaveCard) { + const cardData = this.getCardData(req.body); + + if (cardData) { + await this.updatePromoOperation(db, operationId, token); + await user.saveNewBankCard(cardData); + } + } + + if (!delivery?.promoReceiptSent) { + await this.updatePromoOperation(db, operationId, token); + await this.sendReceipt(workspace, plan, + req.body.IssuerBankCountry === 'RU' ? user.email : undefined, + payment.amount); + await this.updatePromoOperation(db, operationId, token, { promoReceiptSent: true }); + } + } else { + if (operation.status !== BusinessOperationStatus.Rejected) { + await this.updatePromoOperation(db, operationId, token); + const successfulUse = await db.collection('promoCodeRedemptions').findOne({ + promoCodeId: new ObjectId(payment.promoCodeId), + transactionId: payment.transactionId, + }); + + if (successfulUse) { + res.json({ code: 0 }); + + return; + } + + await this.updatePromoOperation(db, operationId, token, { status: BusinessOperationStatus.Rejected }); + } + } + + if (!delivery?.promoNotificationSent) { + const notification: PaymentSuccessNotificationTask | PaymentFailedNotificationTask = succeeded + ? { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: data.workspaceId, + tariffPlanId: data.tariffPlanId, + userId: data.userId, + }, + } + : { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + workspaceId: data.workspaceId, + reason: ReasonCodesTranscript[(req.body as FailRequest).ReasonCode], + }, + }; + + await this.updatePromoOperation(db, operationId, token); + await sendNotification(user, notification); + await this.updatePromoOperation(db, operationId, token, { promoNotificationSent: true }); + } + + res.json({ code: 0 }); + } catch (error) { + console.error('[Billing / Promo] Callback failed', error); + /** + * Return an error so the provider retries failed database operations. + */ + res.status(500).json({ code: 1 }); + } finally { + if (operationId) { + try { + await this.getPromoDatabase().collection('businessOperations') + .updateOne({ + _id: operationId, + promoProcessingToken: token, + }, { + $unset: { + promoProcessingToken: '', + promoProcessingUntil: '', + }, + }); + } catch (error) { + console.error('[Billing / Promo] Could not release callback lease', error); + } + } + } + } + + /** + * Renew callback ownership and fence writes from an expired handler. + * + * @param db - accounts database + * @param operationId - payment operation + * @param token - current callback lease owner + * @param fields - completed payment state or delivery markers + */ + private async updatePromoOperation( + db: Db, + operationId: ObjectId, + token: ObjectId, + fields: { + status?: BusinessOperationStatus; + promoReceiptSent?: boolean; + promoNotificationSent?: boolean; + } = {} + ): Promise { + const now = new Date(); + const result = await db.collection('businessOperations').updateOne({ + _id: operationId, + promoProcessingToken: token, + promoProcessingUntil: { $gt: now }, + ...(fields.status !== undefined ? { status: BusinessOperationStatus.Pending } : {}), + }, { + $set: { + ...fields, + promoProcessingUntil: new Date(now.getTime() + 5 * 60 * 1000), + }, + }); + + if (result.matchedCount !== 1) { + throw new Error('PROMO_PAYMENT_LEASE_LOST'); + } + } + /** * Parses request body and returns data from it * @@ -794,10 +1184,28 @@ status: ${body.Status}` */ if (body.Data) { const parsedData = JSON.parse(body.Data || '{}') as WebhookData; + const isCompletion = req.path === '/pay' || req.path === '/fail'; + const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, isCompletion); + + if ('promoPaymentId' in verifiedData && verifiedData.promoPaymentId) { + /** + * Never override signed promo identity or price with unsigned widget data. + */ + return { + ...verifiedData, + isCardLinkOperation: false, + cloudPayments: parsedData.cloudPayments, + }; + } return { - ...checksumService.parseAndVerifyChecksum(parsedData.checksum), + ...verifiedData, ...parsedData, + promoPaymentId: undefined, + promoCodeId: undefined, + originalAmount: undefined, + discountPercent: undefined, + paymentAmount: undefined, }; } @@ -855,8 +1263,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 +1274,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/application.ts b/src/billing/promoCodes/application.ts new file mode 100644 index 00000000..6ac8339a --- /dev/null +++ b/src/billing/promoCodes/application.ts @@ -0,0 +1,64 @@ +import type { Db, ObjectId } from 'mongodb'; + +/** + * Apply a paid plan once without resetting usage on a callback retry. + * The marker and billing fields are written in the same workspace update. + * An older payment must not overwrite a newer billing period. + * + * @param db - accounts database + * @param workspaceId - paid workspace + * @param planId - paid plan + * @param operationId - stable business operation identifier + * @param paymentDate - stable operation creation time + * @returns whether this payment owns the workspace billing period + */ +export async function applyPromoPlan( + db: Db, + workspaceId: ObjectId, + planId: ObjectId, + operationId: ObjectId, + paymentDate: Date +): Promise { + if (!(paymentDate instanceof Date) || !Number.isFinite(paymentDate.getTime())) { + throw new Error('PROMO_PAYMENT_INVALID_DATE'); + } + + const workspaces = db.collection('workspaces'); + const result = await workspaces.updateOne({ + _id: workspaceId, + promoCodePaymentId: { $ne: operationId }, + $or: [ + { lastChargeDate: { $exists: false } }, + { lastChargeDate: { $lt: paymentDate } }, + { + lastChargeDate: paymentDate, + promoCodePaymentId: { $exists: false }, + }, + { + lastChargeDate: paymentDate, + promoCodePaymentId: { $lt: operationId }, + }, + ], + }, { + $set: { + tariffPlanId: planId, + billingPeriodEventsCount: 0, + lastChargeDate: paymentDate, + promoCodePaymentId: operationId, + }, + }); + + if (result.matchedCount === 1) { + return true; + } + + const workspace = await workspaces.findOne({ _id: workspaceId }, { + projection: { promoCodePaymentId: 1 }, + }); + + if (!workspace) { + throw new Error('Workspace not found'); + } + + return workspace.promoCodePaymentId?.equals(operationId) === true; +} diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts new file mode 100644 index 00000000..fc994722 --- /dev/null +++ b/src/billing/promoCodes/index.ts @@ -0,0 +1,104 @@ +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; + +export interface PromoCode { + _id: ObjectId; + value: string; + discountPercent: number; + isDisabled?: boolean; + maxUses?: number; + planIds?: ObjectId[]; +} + +/** + * Codes are stored in MongoDB in uppercase, without surrounding whitespace. + * + * @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; +} + +/** + * Reject unsupported discounts before preparing a payment. + * + * @param discountPercent - configured percentage for the current payment + */ +function validateDiscountPercent(discountPercent: number): void { + if (!Number.isFinite(discountPercent) || discountPercent < 1 || discountPercent > 99) { + throw new UserInputError('PROMO_CODE_INVALID'); + } +} + +/** + * Calculate the current payment in whole rubles, rounded up, with a 1 RUB floor. + * + * @param originalAmount - regular plan price in rubles + * @param discountPercent - percentage discount, from 1 to 99 + * @returns discounted amount in whole rubles + */ +export function calculateDiscountedAmount(originalAmount: number, discountPercent: number): number { + if (!Number.isFinite(originalAmount) || originalAmount <= 0) { + throw new UserInputError('PROMO_CODE_INVALID_AMOUNT'); + } + + validateDiscountPercent(discountPercent); + + const discountedAmount = originalAmount * (100 - discountPercent) / 100; + const roundedAmount = Math.ceil(discountedAmount); + + return Math.max(1, roundedAmount); +} + +/** + * Missing planIds means all plans; a present array allows only its listed plans. + * Usage limits count successful payments only. + * + * @param db - accounts database containing promoCodes + * @param code - user-provided code + * @param tariffPlanId - selected plan + * @returns validated promo code + */ +export async function findActivePromoCode(db: Db, code: string, tariffPlanId: ObjectId): Promise { + const promoCode = await db.collection('promoCodes').findOne({ + value: normalizePromoCode(code), + }); + + if (!promoCode || promoCode.isDisabled === true) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + validateDiscountPercent(promoCode.discountPercent); + + if (promoCode.planIds !== undefined && ( + !Array.isArray(promoCode.planIds) || + !promoCode.planIds.some(id => id instanceof ObjectId && id.equals(tariffPlanId)) + )) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + return { + ...promoCode, + maxUses: getMaxUses(promoCode.maxUses), + }; +} diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts new file mode 100644 index 00000000..8454f33b --- /dev/null +++ b/src/billing/promoCodes/redemptions.ts @@ -0,0 +1,174 @@ +import type { Db } from 'mongodb'; +import { MongoServerError, ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; +import { calculateDiscountedAmount, findActivePromoCode, getMaxUses } from './index'; + +export interface PromoCodeQuote { + promoCodeId: ObjectId; + originalAmount: number; + finalAmount: number; + discountPercent: number; +} + +export interface PromoCodeRedemption extends PromoCodeQuote { + _id: ObjectId; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + transactionId: string; +} + +export interface PromoCodeInput { + promoCode: string; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + originalAmount: number; +} + +export interface PromoPayment { + promoCodeId: string; + promoPaymentId: string; + userId: string; + workspaceId: string; + tariffPlanId: string; + originalAmount: number; + discountPercent: number; + paymentAmount: number; + transactionId: string; + amount: number; + currency: string; +} + +/** + * Check successful uses only. Parallel payments may both pass before either succeeds. + * + * @param db - accounts database + * @param promoCodeId - selected code + * @param userId - payer + * @param workspaceId - paid workspace + * @param transactionId - same successful callback retry, if present + */ +export async function checkPromoCodeUsage( + db: Db, + promoCodeId: ObjectId, + userId: ObjectId, + workspaceId: ObjectId, + transactionId?: string +): Promise { + const redemptions = db.collection('promoCodeRedemptions'); + const existing = await redemptions.findOne({ + promoCodeId, + $or: [{ userId }, { workspaceId }], + }); + + if (existing) { + const isSameSuccessfulPayment = transactionId && + existing.transactionId === transactionId && + existing.userId.equals(userId) && + existing.workspaceId.equals(workspaceId); + + if (isSameSuccessfulPayment) { + return; + } + + throw new UserInputError('PROMO_CODE_ALREADY_USED'); + } + + const promoCode = await db.collection('promoCodes').findOne({ _id: promoCodeId }); + + if (!promoCode) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + const maxUses = getMaxUses(promoCode.maxUses); + + if (maxUses !== undefined && await redemptions.countDocuments({ promoCodeId }) + 1 > maxUses) { + throw new UserInputError('PROMO_CODE_INVALID'); + } +} + +/** + * Calculate a signed quote without writing a usage record. + * + * @param db - accounts database + * @param input - requested payment + * @returns discounted quote + */ +export async function preparePromoCode(db: Db, input: PromoCodeInput): Promise { + const promoCode = await findActivePromoCode(db, input.promoCode, input.tariffPlanId); + const finalAmount = calculateDiscountedAmount(input.originalAmount, promoCode.discountPercent); + + await checkPromoCodeUsage(db, promoCode._id, input.userId, input.workspaceId); + + return { + promoCodeId: promoCode._id, + originalAmount: input.originalAmount, + finalAmount, + discountPercent: promoCode.discountPercent, + }; +} + +/** + * Validate the signed quote against the successful payment callback. + * + * @param payment - signed identity and provider callback fields + */ +export function validatePromoPayment(payment: PromoPayment): void { + const ids = [payment.promoCodeId, payment.promoPaymentId, 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'); + } + + const expectedAmount = calculateDiscountedAmount(payment.originalAmount, payment.discountPercent); + + if (payment.paymentAmount !== expectedAmount || payment.amount !== expectedAmount) { + throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); + } +} + +/** + * Record promo usage only after the provider reports a successful payment. + * + * @param db - accounts database + * @param payment - verified successful payment + */ +export async function recordPromoCodeUsage(db: Db, payment: PromoPayment): Promise { + validatePromoPayment(payment); + + const redemptions = db.collection('promoCodeRedemptions'); + 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.paymentAmount, + discountPercent: payment.discountPercent, + }; + + try { + await redemptions.insertOne(usage); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) { + throw error; + } + + const existing = await redemptions.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/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index fb554dd9..b4f22309 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -60,4 +60,19 @@ export interface PaymentData { * True if this is card linking operation โ€“ charging minimal amount of money to validate card info */ isCardLinkOperation: boolean; + /** + * Signed amount of the current promo payment in rubles + */ + paymentAmount?: number; + /** + * Signed promo payment attempt and price snapshot + */ + promoPaymentId?: string; + promoCodeId?: string; + originalAmount?: number; + discountPercent?: number; + /** + * Signed start date for future full-price payments + */ + nextPaymentDate?: string; } diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d5d96873..6e2b1c2b 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -8,6 +8,9 @@ import { PayloadOfWorkspacePlanPurchase } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; +import { ObjectId } from 'mongodb'; +import { databases } from '../mongo'; +import { preparePromoCode, PromoCodeQuote } from '../billing/promoCodes/redemptions'; import { UserInputError } from 'apollo-server-express'; import cloudPaymentsApi, { CloudPaymentsJsonData } from '../utils/cloudPaymentsApi'; import * as telegram from '../utils/telegram'; @@ -27,6 +30,7 @@ interface ComposePaymentArgs { workspaceId: string; tariffPlanId: string; shouldSaveCard?: boolean; + promoCode?: string | null; }; } @@ -83,12 +87,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'); @@ -126,6 +132,33 @@ export default { 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'); + } + + if (!databases.hawk) { + throw new Error('Accounts database is not connected'); + } + + promoQuote = await preparePromoCode(databases.hawk, { + promoCode, + userId: new ObjectId(user.id), + workspaceId: workspace._id, + tariffPlanId: plan._id, + originalAmount: plan.monthlyCharge, + }); + + paymentAmount = promoQuote.finalAmount; + } + /** * Calculate next payment date */ @@ -151,6 +184,15 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), + ...(promoQuote + ? { + paymentAmount, + promoPaymentId: new ObjectId().toString(), + promoCodeId: promoQuote.promoCodeId.toString(), + originalAmount: promoQuote.originalAmount, + discountPercent: promoQuote.discountPercent, + } + : {}), }; const checksum = await checksumService.generateChecksum(checksumData); @@ -162,7 +204,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 +220,7 @@ debug: ${Boolean(workspace.isDebug)}` monthlyCharge: plan.monthlyCharge, }, isCardLinkOperation, + paymentAmount, currency: 'RUB', checksum, nextPaymentDate, 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/utils/checksumService.ts b/src/utils/checksumService.ts index 59601fb1..07f77527 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,5 +1,5 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; +import { UserInputError } from 'apollo-server-express'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -24,6 +24,17 @@ interface PlanPurchaseChecksumData { * Next payment date */ nextPaymentDate: string; + /** + * Discounted amount for the current payment in rubles + */ + paymentAmount?: number; + /** + * Signed identity and price of the promo payment attempt + */ + promoPaymentId?: string; + promoCodeId?: string; + originalAmount?: number; + discountPercent?: number; } interface CardLinkChecksumData { @@ -55,20 +66,29 @@ class ChecksumService { * @param data - data for processing billing request */ 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 */ - 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 = 'promoPaymentId' in payload && Boolean(payload.promoPaymentId); + + if ('promoCodeRedemptionId' in payload) { + throw new UserInputError('PROMO_CODE_INVALID'); + } + + if (allowExpiredPromo && !isPromoPayment) { + jwt.verify(checksum, secret); + } if ('isCardLinkOperation' in payload) { return { @@ -84,6 +104,15 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, + ...(payload.promoPaymentId + ? { + paymentAmount: payload.paymentAmount, + promoPaymentId: payload.promoPaymentId, + promoCodeId: payload.promoCodeId, + originalAmount: payload.originalAmount, + discountPercent: payload.discountPercent, + } + : {}), }; } } diff --git a/test/services/promoCodes/application.test.ts b/test/services/promoCodes/application.test.ts new file mode 100644 index 00000000..34afcd6e --- /dev/null +++ b/test/services/promoCodes/application.test.ts @@ -0,0 +1,131 @@ +import { Db, MongoClient, ObjectId } from 'mongodb'; +import { applyPromoPlan } from '../../../src/billing/promoCodes/application'; + +const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); + +describe('Promo plan application', () => { + let client: MongoClient; + let db: Db; + let workspaceId: ObjectId; + let planId: ObjectId; + let operationId: ObjectId; + + 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-plan-application-tests'); + }); + + beforeEach(async () => { + await db.dropDatabase(); + workspaceId = new ObjectId(); + planId = new ObjectId(); + operationId = new ObjectId(); + await db.collection('workspaces').insertOne({ + _id: workspaceId, + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 50, + lastChargeDate: new Date(PAYMENT_DATE.getTime() - 24 * 60 * 60 * 1000), + }); + }); + + afterAll(async () => { + if (db) { + await db.dropDatabase(); + } + + if (client) { + await client.close(); + } + }); + + test('should apply a paid plan and store its stable payment date and marker', async () => { + const date = PAYMENT_DATE; + + const result = await applyPromoPlan(db, workspaceId, planId, operationId, date); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(true); + expect(workspace).toMatchObject({ + tariffPlanId: planId, + billingPeriodEventsCount: 0, + lastChargeDate: date, + promoCodePaymentId: operationId, + }); + }); + + test('should not reset usage or advance the billing date when the payment is retried', async () => { + await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + await db.collection('workspaces').updateOne({ _id: workspaceId }, { + $set: { billingPeriodEventsCount: 7 }, + }); + + const result = await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(true); + expect(workspace?.billingPeriodEventsCount).toBe(7); + expect(workspace?.lastChargeDate).toEqual(PAYMENT_DATE); + }); + + test('should not overwrite a newer paid plan with an older callback', async () => { + await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + + const newerPlanId = new ObjectId(); + const newerOperationId = new ObjectId(); + const newerDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); + + await applyPromoPlan(db, workspaceId, newerPlanId, newerOperationId, newerDate); + + const result = await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(false); + expect(workspace?.tariffPlanId).toEqual(newerPlanId); + expect(workspace?.lastChargeDate).toEqual(newerDate); + expect(workspace?.promoCodePaymentId).toEqual(newerOperationId); + }); + + test('should preserve usage after concurrent callbacks for the same payment', async () => { + const requests = [operationId, operationId]; + + const results = await Promise.all(requests.map(id => applyPromoPlan(db, workspaceId, planId, id, PAYMENT_DATE))); + + await db.collection('workspaces').updateOne({ _id: workspaceId }, { + $set: { billingPeriodEventsCount: 7 }, + }); + await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(results).toEqual([true, true]); + expect(workspace?.billingPeriodEventsCount).toBe(7); + expect(workspace?.promoCodePaymentId).toEqual(operationId); + }); + + test('should reject a missing workspace', async () => { + const missingWorkspaceId = new ObjectId(); + + const result = applyPromoPlan(db, missingWorkspaceId, planId, operationId, PAYMENT_DATE); + + await expect(result).rejects.toThrow('Workspace not found'); + }); + + test('should reject an invalid payment date without changing the workspace', async () => { + const invalidDate = new Date('invalid'); + + const result = applyPromoPlan(db, workspaceId, planId, operationId, invalidDate); + + await expect(result).rejects.toThrow('PROMO_PAYMENT_INVALID_DATE'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace?.billingPeriodEventsCount).toBe(50); + expect(workspace?.promoCodePaymentId).toBeUndefined(); + }); +}); diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts new file mode 100644 index 00000000..7c6ab5de --- /dev/null +++ b/test/services/promoCodes/index.test.ts @@ -0,0 +1,199 @@ +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; +import { + calculateDiscountedAmount, + findActivePromoCode, + getMaxUses, + normalizePromoCode, + PromoCode +} from '../../../src/billing/promoCodes'; + +describe('Promo code normalization', () => { + test('should trim whitespace and normalize case', () => { + const code = ' sale20 '; + + const result = normalizePromoCode(code); + + expect(result).toBe('SALE20'); + }); + + test.each(['', ' '])('should reject an empty code: %j', code => { + const action = (): string => normalizePromoCode(code); + + expect(action).toThrow('PROMO_CODE_INVALID'); + }); +}); + +describe('Promo code usage limit', () => { + test.each([0, 1, 10])('should preserve numeric limit %j', value => { + const result = getMaxUses(value); + + expect(result).toBe(value); + }); + + test.each([undefined, null, -1, '10', '0', 'invalid', NaN, Infinity, false])( + 'should treat %j as unlimited without converting strings', value => { + const result = getMaxUses(value); + + expect(result).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], + ])('should discount %j RUB by %j percent to %j RUB', (amount, percent, expected) => { + const result = calculateDiscountedAmount(amount, percent); + + expect(result).toBe(expected); + }); + + test.each([0, -1, NaN, Infinity])('should reject invalid amount %j', amount => { + const discountPercent = 20; + + const action = (): number => calculateDiscountedAmount(amount, discountPercent); + + expect(action).toThrow('PROMO_CODE_INVALID_AMOUNT'); + }); + + test.each([0, -1, 100, NaN, Infinity])('should reject invalid discount %j', percent => { + const originalAmount = 100; + + const action = (): number => calculateDiscountedAmount(originalAmount, percent); + + expect(action).toThrow('PROMO_CODE_INVALID'); + }); +}); + +describe('Active promo code lookup', () => { + const findOne = jest.fn(); + const collection = jest.fn().mockReturnValue({ findOne }); + const db = { collection } as unknown as Db; + let promoCode: PromoCode; + let planId: ObjectId; + + beforeEach(() => { + jest.clearAllMocks(); + planId = new ObjectId(); + promoCode = { + _id: new ObjectId(), + value: 'SALE20', + discountPercent: 20, + }; + findOne.mockResolvedValue(promoCode); + }); + + test('should allow any plan when planIds is absent', async () => { + const code = ' sale20 '; + + const result = await findActivePromoCode(db, code, planId); + + expect(result).toEqual(promoCode); + expect(collection).toHaveBeenCalledWith('promoCodes'); + expect(findOne).toHaveBeenCalledWith({ value: 'SALE20' }); + }); + + test('should reject a code that does not exist', async () => { + findOne.mockResolvedValue(null); + + const result = findActivePromoCode(db, 'UNKNOWN', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test('should reject a disabled code', async () => { + promoCode.isDisabled = true; + + const result = findActivePromoCode(db, 'SALE20', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test.each([undefined, false, null, 0, '', 'true'])('should allow a code when isDisabled is %j', async isDisabled => { + findOne.mockResolvedValue({ + ...promoCode, + isDisabled, + }); + + const result = await findActivePromoCode(db, 'SALE20', planId); + + expect(result).toMatchObject({ isDisabled }); + }); + + test('should allow a plan explicitly listed in planIds', async () => { + promoCode.planIds = [new ObjectId(), planId]; + + const result = await findActivePromoCode(db, 'SALE20', planId); + + expect(result).toEqual(promoCode); + }); + + test('should reject a plan not listed in planIds', async () => { + promoCode.planIds = [ new ObjectId() ]; + + const result = findActivePromoCode(db, 'SALE20', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test('should allow no plans when planIds is empty', async () => { + promoCode.planIds = []; + + const result = findActivePromoCode(db, 'SALE20', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test.each([ + { planIds: null }, + { planIds: 'all' }, + { planIds: [ null ] }, + { planIds: [ 'not-a-plan-id' ] }, + ])('should reject malformed planIds %j', async ({ planIds }) => { + findOne.mockResolvedValue({ + ...promoCode, + planIds, + }); + + const result = findActivePromoCode(db, 'SALE20', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test('should reject an invalid discount from the database', async () => { + promoCode.discountPercent = 100; + + const result = findActivePromoCode(db, 'SALE20', planId); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + }); + + test('should preserve zero for later enforcement during reservation', async () => { + promoCode.maxUses = 0; + + const result = await findActivePromoCode(db, 'SALE20', planId); + + expect(result).toMatchObject({ maxUses: 0 }); + }); + + test('should treat a string limit from the database as unlimited', async () => { + const storedPromoCode = { + ...promoCode, + maxUses: '10', + }; + + findOne.mockResolvedValue(storedPromoCode); + + const result = await findActivePromoCode(db, 'SALE20', planId); + + expect(result.maxUses).toBeUndefined(); + expect(storedPromoCode.maxUses).toBe('10'); + }); +}); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts new file mode 100644 index 00000000..d14ecf09 --- /dev/null +++ b/test/services/promoCodes/redemptions.test.ts @@ -0,0 +1,187 @@ +import type { Db } from 'mongodb'; +import { MongoServerError, ObjectId } from 'mongodb'; +import { preparePromoCode, recordPromoCodeUsage, validatePromoPayment } from '../../../src/billing/promoCodes/redemptions'; +import type { PromoCodeInput, PromoPayment, PromoCodeRedemption } from '../../../src/billing/promoCodes/redemptions'; +import type { PromoCode } from '../../../src/billing/promoCodes'; + +describe('Promo code 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; + + beforeEach(() => { + jest.resetAllMocks(); + promoCode = { _id: new ObjectId(), value: 'SALE20', discountPercent: 20 }; + input = { + promoCode: ' sale20 ', + userId: new ObjectId(), + workspaceId: new ObjectId(), + tariffPlanId: new ObjectId(), + originalAmount: 100, + }; + payment = { + promoCodeId: promoCode._id.toString(), + promoPaymentId: new ObjectId().toString(), + userId: input.userId.toString(), + workspaceId: input.workspaceId.toString(), + tariffPlanId: input.tariffPlanId.toString(), + originalAmount: 100, + discountPercent: 20, + paymentAmount: 80, + transactionId: 'payment-123', + amount: 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, + discountPercent: 20, + }; + 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 }); + }); + + test('should prepare a discounted quote without creating a usage record', async () => { + const expectedAmount = 80; + + const result = await preparePromoCode(db, input); + + expect(result).toEqual({ + promoCodeId: promoCode._id, + originalAmount: 100, + finalAmount: expectedAmount, + discountPercent: 20, + }); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should allow repeated preparation while no successful use exists', async () => { + const first = await preparePromoCode(db, input); + + const second = await preparePromoCode(db, input); + + expect(second).toEqual(first); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test.each(['userId', 'workspaceId'] as const)('should reject successful reuse by the same %s', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + + existingUse[otherField] = new ObjectId(); + findRedemption.mockResolvedValue(existingUse); + + const result = preparePromoCode(db, input); + + await expect(result).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); + + const result = preparePromoCode(db, input); + + await expect(result).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 }); + + const result = await preparePromoCode(db, input); + + expect(result.finalAmount).toBe(80); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test('should reject a plan outside the allowed list', async () => { + promoCode.planIds = [ new ObjectId() ]; + + const result = preparePromoCode(db, input); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should record only successful usage without status or expiry fields', async () => { + const callback = payment; + + await recordPromoCodeUsage(db, callback); + + expect(insertOne).toHaveBeenCalledWith({ + ...existingUse, + _id: expect.any(ObjectId), + }); + const stored = insertOne.mock.calls[0][0]; + + expect(stored).not.toHaveProperty('status'); + expect(stored).not.toHaveProperty('expiresAt'); + }); + + test('should not create another row when the same successful callback is repeated', async () => { + findRedemption.mockResolvedValue(existingUse); + insertOne.mockRejectedValue(new MongoServerError({ code: 11000, message: 'Duplicate key' })); + + const result = recordPromoCodeUsage(db, payment); + + await expect(result).resolves.toBeUndefined(); + }); + + test('should not lose the paid plan when a parallel successful payment uses the same code', async () => { + 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 { + const result = recordPromoCodeUsage(db, payment); + + await expect(result).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test('should propagate a database failure so the callback can be retried', async () => { + const error = new Error('Database unavailable'); + + insertOne.mockRejectedValue(error); + + const result = recordPromoCodeUsage(db, payment); + + await expect(result).rejects.toBe(error); + }); + + test.each([1, 81, NaN])('should reject a callback with a changed amount %j', amount => { + payment.amount = amount; + + const action = (): void => validatePromoPayment(payment); + + expect(action).toThrow('PROMO_CODE_WRONG_AMOUNT'); + expect(insertOne).not.toHaveBeenCalled(); + }); +});