From 7534e060aaf2b03238e967995e0d94119d108137 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 13 Oct 2026 14:17:10 +0300 Subject: [PATCH 1/6] step 1 --- ...013000000-create-promo-code-collections.js | 36 +++ src/billing/promoCodes.ts | 129 +++++++++++ test/services/promoCodes.test.ts | 217 ++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 migrations/20261013000000-create-promo-code-collections.js create mode 100644 src/billing/promoCodes.ts create mode 100644 test/services/promoCodes.test.ts 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/src/billing/promoCodes.ts b/src/billing/promoCodes.ts new file mode 100644 index 00000000..01ea3336 --- /dev/null +++ b/src/billing/promoCodes.ts @@ -0,0 +1,129 @@ +import type { Db, ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; + +export interface PromoCode { + _id: ObjectId; + value: string; + discountPercent: number; + isActive: boolean; + createdAt: Date; + validFrom?: Date; + validUntil?: Date; + maxUses?: number; +} + +/** + * 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_NOT_FOUND'); + } + + 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_DISCOUNT'); + } +} + +/** + * 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); +} + +/** + * Reject malformed dates in manually configured promo codes. + * + * @param date - optional BSON date stored in the promo code document + */ +function validatePromoDate(date: Date | undefined): void { + if (date !== undefined && (!(date instanceof Date) || !Number.isFinite(date.getTime()))) { + throw new UserInputError('PROMO_CODE_INVALID_DATES'); + } +} + +/** + * Validate the code configuration without reserving or consuming a use. + * The validUntil boundary is exclusive. Usage limits are enforced at reservation. + * + * @param db - accounts database containing promoCodes + * @param code - user-provided code + * @param now - validation time + */ +export async function findActivePromoCode(db: Db, code: string, now = new Date()): Promise { + const normalizedCode = normalizePromoCode(code); + const promoCode = await db.collection('promoCodes').findOne({ + value: normalizedCode, + }); + + if (!promoCode) { + throw new UserInputError('PROMO_CODE_NOT_FOUND'); + } + + if (promoCode.isActive !== true) { + throw new UserInputError('PROMO_CODE_INACTIVE'); + } + + const { discountPercent, validFrom, validUntil } = promoCode; + + validateDiscountPercent(discountPercent); + validatePromoDate(validFrom); + validatePromoDate(validUntil); + + if (validFrom && validUntil && validFrom >= validUntil) { + throw new UserInputError('PROMO_CODE_INVALID_DATES'); + } + + if (validFrom && now < validFrom) { + throw new UserInputError('PROMO_CODE_NOT_STARTED'); + } + + if (validUntil && now >= validUntil) { + throw new UserInputError('PROMO_CODE_EXPIRED'); + } + + return { + ...promoCode, + maxUses: getMaxUses(promoCode.maxUses), + }; +} diff --git a/test/services/promoCodes.test.ts b/test/services/promoCodes.test.ts new file mode 100644 index 00000000..4da5cb0e --- /dev/null +++ b/test/services/promoCodes.test.ts @@ -0,0 +1,217 @@ +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; +import { + calculateDiscountedAmount, + findActivePromoCode, + getMaxUses, + normalizePromoCode, + PromoCode +} from '../../src/billing/promoCodes'; + +const NOW = new Date('2026-10-13T12:00:00.000Z'); + +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 invalidCode = code; + + const action = (): string => normalizePromoCode(invalidCode); + + expect(action).toThrow('PROMO_CODE_NOT_FOUND'); + }); +}); + +describe('Promo code usage limit', () => { + test.each([0, 1, 10])('should preserve numeric limit %j', value => { + const configuredLimit = value; + + const result = getMaxUses(configuredLimit); + + 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 configuredLimit = value; + + const result = getMaxUses(configuredLimit); + + 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 originalAmount = amount; + const discountPercent = percent; + + const result = calculateDiscountedAmount(originalAmount, discountPercent); + + 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_DISCOUNT'); + }); +}); + +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; + + beforeEach(() => { + jest.clearAllMocks(); + promoCode = { + _id: new ObjectId(), + value: 'SALE20', + discountPercent: 20, + isActive: true, + createdAt: NOW, + }; + findOne.mockResolvedValue(promoCode); + }); + + test('should read a normalized code without modifying the database', async () => { + const code = ' sale20 '; + + const result = await findActivePromoCode(db, code, NOW); + + 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', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_NOT_FOUND'); + }); + + test('should reject an inactive code', async () => { + promoCode.isActive = false; + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INACTIVE'); + }); + + test('should reject a code before its start date', async () => { + promoCode.validFrom = new Date(NOW.getTime() + 1); + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_NOT_STARTED'); + }); + + test('should accept a code exactly at its start date', async () => { + promoCode.validFrom = NOW; + + const result = await findActivePromoCode(db, 'SALE20', NOW); + + expect(result).toEqual(promoCode); + }); + + test('should reject an expired code', async () => { + promoCode.validUntil = new Date(NOW.getTime() - 1); + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_EXPIRED'); + }); + + test('should reject a code exactly at its expiration date', async () => { + promoCode.validUntil = NOW; + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_EXPIRED'); + }); + + test('should accept a code within its validity period', async () => { + promoCode.validFrom = new Date(NOW.getTime() - 1); + promoCode.validUntil = new Date(NOW.getTime() + 1); + + const result = await findActivePromoCode(db, 'SALE20', NOW); + + expect(result).toEqual(promoCode); + }); + + test('should reject malformed dates from manually created documents', async () => { + promoCode.validUntil = new Date('invalid'); + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DATES'); + }); + + test('should reject a reversed validity period', async () => { + promoCode.validFrom = new Date(NOW.getTime() + 1); + promoCode.validUntil = new Date(NOW.getTime() - 1); + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DATES'); + }); + + test('should reject an invalid discount from the database', async () => { + promoCode.discountPercent = 100; + + const result = findActivePromoCode(db, 'SALE20', NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DISCOUNT'); + }); + + test('should preserve zero for later enforcement during reservation', async () => { + promoCode.maxUses = 0; + + const result = await findActivePromoCode(db, 'SALE20', NOW); + + 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', NOW); + + expect(result.maxUses).toBeUndefined(); + expect(storedPromoCode.maxUses).toBe('10'); + }); +}); From 8b42f9fb5cbb964011886bcf029abab9148bf6c7 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 13 Oct 2026 14:46:14 +0300 Subject: [PATCH 2/6] redemptions --- .../{promoCodes.ts => promoCodes/index.ts} | 0 src/billing/promoCodes/redemptions.ts | 143 +++++++++ .../index.test.ts} | 2 +- test/services/promoCodes/redemptions.test.ts | 282 ++++++++++++++++++ 4 files changed, 426 insertions(+), 1 deletion(-) rename src/billing/{promoCodes.ts => promoCodes/index.ts} (100%) create mode 100644 src/billing/promoCodes/redemptions.ts rename test/services/{promoCodes.test.ts => promoCodes/index.test.ts} (99%) create mode 100644 test/services/promoCodes/redemptions.test.ts diff --git a/src/billing/promoCodes.ts b/src/billing/promoCodes/index.ts similarity index 100% rename from src/billing/promoCodes.ts rename to src/billing/promoCodes/index.ts diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts new file mode 100644 index 00000000..e284ddc9 --- /dev/null +++ b/src/billing/promoCodes/redemptions.ts @@ -0,0 +1,143 @@ +import type { Db } from 'mongodb'; +import { MongoServerError, ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; +import { calculateDiscountedAmount, findActivePromoCode } from './index'; + +const RESERVATION_DURATION_MS = 30 * 60 * 1000; + +export interface PromoCodeRedemption { + _id: ObjectId; + promoCodeId: ObjectId; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + status: 'reserved' | 'used'; + originalAmount: number; + finalAmount: number; + discountPercent: number; + createdAt: Date; + expiresAt?: Date; + transactionId?: string; + usedAt?: Date; +} + +export interface ReservePromoCodeInput { + promoCode: string; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + originalAmount: number; +} + +/** + * Reuse only an unexpired reservation for the exact same payment preparation. + * A reservation bound to a provider transaction must not start another payment. + * + * @param reservation - existing use by this user or workspace + * @param input - requested payment + * @param now - reservation time + * @returns existing reservation, or throws if reuse is not allowed + */ +function reuseReservation( + reservation: PromoCodeRedemption, + input: ReservePromoCodeInput, + now: Date +): PromoCodeRedemption { + const samePayment = reservation.userId.equals(input.userId) && + reservation.workspaceId.equals(input.workspaceId) && + reservation.tariffPlanId.equals(input.tariffPlanId) && + reservation.originalAmount === input.originalAmount; + const canReuse = reservation.status === 'reserved' && + reservation.expiresAt && reservation.expiresAt > now && + !reservation.transactionId; + + if (!samePayment || !canReuse) { + throw new UserInputError('PROMO_CODE_ALREADY_USED'); + } + + return reservation; +} + +/** + * Reserve a discounted payment without changing the promo code's configured limit. + * Unique indexes enforce the per-user and per-workspace restrictions. + * + * @param db - accounts database + * @param input - payment being prepared + * @param now - reservation time + * @returns new reservation or the existing reservation for an identical retry + */ +export async function reservePromoCode( + db: Db, + input: ReservePromoCodeInput, + now = new Date() +): Promise { + const promoCode = await findActivePromoCode(db, input.promoCode, now); + const finalAmount = calculateDiscountedAmount(input.originalAmount, promoCode.discountPercent); + const redemptions = db.collection('promoCodeRedemptions'); + const existingUseQuery = { + promoCodeId: promoCode._id, + $or: [ + { userId: input.userId }, + { workspaceId: input.workspaceId }, + ], + }; + const existingReservation = await redemptions.findOne(existingUseQuery); + + if (existingReservation) { + return reuseReservation(existingReservation, input, now); + } + + if (promoCode.maxUses !== undefined) { + const occupiedUses = await redemptions.countDocuments({ + promoCodeId: promoCode._id, + status: { $in: ['reserved', 'used'] }, + }); + + /** + * MVP trade-off: count and insert are not atomic. Concurrent users/workspaces + * may exceed maxUses; strict global enforcement is intentionally deferred. + * Reservations keep occupying quota until explicitly released, not by TTL. + */ + if (occupiedUses + 1 > promoCode.maxUses) { + throw new UserInputError('PROMO_CODE_LIMIT_REACHED'); + } + } + + const reservationDeadline = now.getTime() + RESERVATION_DURATION_MS; + const expiresAt = new Date(Math.min( + reservationDeadline, + promoCode.validUntil?.getTime() ?? reservationDeadline + )); + const reservation: PromoCodeRedemption = { + _id: new ObjectId(), + promoCodeId: promoCode._id, + userId: input.userId, + workspaceId: input.workspaceId, + tariffPlanId: input.tariffPlanId, + status: 'reserved', + originalAmount: input.originalAmount, + finalAmount, + discountPercent: promoCode.discountPercent, + createdAt: now, + expiresAt, + }; + + try { + await redemptions.insertOne(reservation); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) { + throw error; + } + + const concurrentReservation = await redemptions.findOne(existingUseQuery); + + if (concurrentReservation) { + return reuseReservation(concurrentReservation, input, now); + } + + throw error; + } + + return reservation; +} diff --git a/test/services/promoCodes.test.ts b/test/services/promoCodes/index.test.ts similarity index 99% rename from test/services/promoCodes.test.ts rename to test/services/promoCodes/index.test.ts index 4da5cb0e..508bc0a5 100644 --- a/test/services/promoCodes.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -6,7 +6,7 @@ import { getMaxUses, normalizePromoCode, PromoCode -} from '../../src/billing/promoCodes'; +} from '../../../src/billing/promoCodes'; const NOW = new Date('2026-10-13T12:00:00.000Z'); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts new file mode 100644 index 00000000..1045b96d --- /dev/null +++ b/test/services/promoCodes/redemptions.test.ts @@ -0,0 +1,282 @@ +import type { Db } from 'mongodb'; +import { MongoServerError, ObjectId } from 'mongodb'; +import { + PromoCodeRedemption, + ReservePromoCodeInput, + reservePromoCode +} from '../../../src/billing/promoCodes/redemptions'; +import type { PromoCode } from '../../../src/billing/promoCodes'; + +const NOW = new Date('2026-10-13T12:00:00.000Z'); +const RESERVATION_DEADLINE = new Date(NOW.getTime() + 30 * 60 * 1000); + +describe('Promo code reservation', () => { + 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: ReservePromoCodeInput; + let existingReservation: PromoCodeRedemption; + + beforeEach(() => { + jest.resetAllMocks(); + promoCode = { + _id: new ObjectId(), + value: 'SALE20', + discountPercent: 20, + isActive: true, + createdAt: NOW, + }; + input = { + promoCode: ' sale20 ', + userId: new ObjectId(), + workspaceId: new ObjectId(), + tariffPlanId: new ObjectId(), + originalAmount: 100, + }; + existingReservation = { + _id: new ObjectId(), + promoCodeId: promoCode._id, + userId: input.userId, + workspaceId: input.workspaceId, + tariffPlanId: input.tariffPlanId, + status: 'reserved', + originalAmount: 100, + finalAmount: 80, + discountPercent: 20, + createdAt: NOW, + expiresAt: RESERVATION_DEADLINE, + }; + collection.mockImplementation((name: string) => { + if (name === 'promoCodes') { + return { findOne: findPromoCode }; + } + + if (name === 'promoCodeRedemptions') { + return { + findOne: findRedemption, + countDocuments, + insertOne, + }; + } + + throw new Error(`Unexpected collection: ${name}`); + }); + findPromoCode.mockResolvedValue(promoCode); + findRedemption.mockResolvedValue(null); + countDocuments.mockResolvedValue(0); + insertOne.mockResolvedValue({ acknowledged: true }); + }); + + test('should store the user, workspace, plan and discounted price in a reservation', async () => { + const expectedPrice = 80; + + const result = await reservePromoCode(db, input, NOW); + + expect(result._id).toBeInstanceOf(ObjectId); + expect(result).toMatchObject({ + promoCodeId: promoCode._id, + userId: input.userId, + workspaceId: input.workspaceId, + tariffPlanId: input.tariffPlanId, + status: 'reserved', + originalAmount: 100, + finalAmount: expectedPrice, + discountPercent: 20, + createdAt: NOW, + expiresAt: RESERVATION_DEADLINE, + }); + expect(findPromoCode).toHaveBeenCalledWith({ value: 'SALE20' }); + expect(insertOne).toHaveBeenCalledWith(result); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test('should not let a reservation outlive the promo code', async () => { + promoCode.validUntil = new Date(NOW.getTime() + 10 * 60 * 1000); + + const result = await reservePromoCode(db, input, NOW); + + expect(result.expiresAt).toEqual(promoCode.validUntil); + }); + + test('should reuse an identical reservation without consuming quota again', async () => { + promoCode.maxUses = 1; + findRedemption.mockResolvedValue(existingReservation); + countDocuments.mockResolvedValue(1); + + const result = await reservePromoCode(db, input, NOW); + + expect(result).toBe(existingReservation); + expect(countDocuments).not.toHaveBeenCalled(); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should keep the stored price when reusing a reservation', async () => { + promoCode.discountPercent = 30; + findRedemption.mockResolvedValue(existingReservation); + + const result = await reservePromoCode(db, input, NOW); + + expect(result.finalAmount).toBe(80); + expect(result.discountPercent).toBe(20); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should reject a code already used by the same user in another workspace', async () => { + existingReservation.workspaceId = new ObjectId(); + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should reject a code already used by another user in the same workspace', async () => { + existingReservation.userId = new ObjectId(); + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should reject a successful use even for the same payment details', async () => { + existingReservation.status = 'used'; + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should not reuse an expired reservation before it is released', async () => { + existingReservation.expiresAt = NOW; + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should not reuse a reservation already bound to a payment', async () => { + existingReservation.transactionId = 'payment-123'; + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should not reuse a reservation for another plan', async () => { + existingReservation.tariffPlanId = new ObjectId(); + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should not reuse a reservation for another original price', async () => { + existingReservation.originalAmount = 200; + findRedemption.mockResolvedValue(existingReservation); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test('should count reserved and used records before reserving the last available use', async () => { + promoCode.maxUses = 2; + countDocuments.mockResolvedValue(1); + + const result = await reservePromoCode(db, input, NOW); + + expect(result.status).toBe('reserved'); + expect(countDocuments).toHaveBeenCalledWith({ + promoCodeId: promoCode._id, + status: { $in: ['reserved', 'used'] }, + }); + expect(insertOne).toHaveBeenCalledTimes(1); + expect(promoCode.maxUses).toBe(2); + }); + + test.each([0, 1, 2])('should reject a reservation when limit %j is exhausted', async maxUses => { + promoCode.maxUses = maxUses; + countDocuments.mockResolvedValue(maxUses); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_LIMIT_REACHED'); + expect(insertOne).not.toHaveBeenCalled(); + }); + + test.each(['10', -1, null, undefined, NaN, Infinity])( + 'should allow unlimited use when maxUses is %j', async maxUses => { + findPromoCode.mockResolvedValue({ + ...promoCode, + maxUses, + }); + + const result = await reservePromoCode(db, input, NOW); + + expect(result.status).toBe('reserved'); + expect(countDocuments).not.toHaveBeenCalled(); + expect(insertOne).toHaveBeenCalledTimes(1); + } + ); + + test('should reuse the same reservation created by a concurrent request', async () => { + findRedemption.mockResolvedValueOnce(null).mockResolvedValueOnce(existingReservation); + insertOne.mockRejectedValue(new MongoServerError({ + message: 'Duplicate key', + code: 11000, + })); + + const result = await reservePromoCode(db, input, NOW); + + expect(result).toBe(existingReservation); + expect(findRedemption).toHaveBeenCalledTimes(2); + }); + + test('should reject a concurrent duplicate use by another user', async () => { + existingReservation.userId = new ObjectId(); + findRedemption.mockResolvedValueOnce(null).mockResolvedValueOnce(existingReservation); + insertOne.mockRejectedValue(new MongoServerError({ + message: 'Duplicate key', + code: 11000, + })); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + }); + + test('should propagate a database write failure', async () => { + const error = new Error('Database unavailable'); + + insertOne.mockRejectedValue(error); + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toBe(error); + }); + + test('should not reserve an inactive promo code', async () => { + promoCode.isActive = false; + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INACTIVE'); + expect(insertOne).not.toHaveBeenCalled(); + }); +}); From 6b5988fa2c05ad614d7859f9d1e808f36e41b950 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 13 Oct 2026 17:00:28 +0300 Subject: [PATCH 3/6] chore --- package.json | 2 +- src/billing/cloudpayments.ts | 417 ++++++++++++++++++- src/billing/promoCodes/application.ts | 64 +++ src/billing/promoCodes/lifecycle.ts | 258 ++++++++++++ src/billing/promoCodes/redemptions.ts | 4 + src/billing/types/paymentData.ts | 12 + src/resolvers/billingNew.ts | 47 ++- src/typeDefs/billing.ts | 10 + src/utils/checksumService.ts | 47 ++- test/services/promoCodes/application.test.ts | 131 ++++++ test/services/promoCodes/lifecycle.test.ts | 358 ++++++++++++++++ test/services/promoCodes/redemptions.test.ts | 3 + 12 files changed, 1338 insertions(+), 15 deletions(-) create mode 100644 src/billing/promoCodes/application.ts create mode 100644 src/billing/promoCodes/lifecycle.ts create mode 100644 test/services/promoCodes/application.test.ts create mode 100644 test/services/promoCodes/lifecycle.test.ts 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..2fc4c33d 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -1,4 +1,14 @@ import express from 'express'; +import { ObjectId, Db } from 'mongodb'; +import { databases } from '../mongo'; +import { + bindPromoCodeReservation, + getPromoCodeQuote, + completePromoCodeReservation, + releasePromoCodeReservation, + PromoPayment +} from './promoCodes/lifecycle'; +import { applyPromoPlan } from './promoCodes/application'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import { @@ -22,8 +32,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 +168,12 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promoCodeRedemptionId) { + await this.checkPromoPayment(req, res, data, workspace, plan); + + return; + } + const recurrentPaymentSettings = data.cloudPayments?.recurrent; /** @@ -250,6 +265,12 @@ export default class CloudPaymentsWebhooks { } } + if (data.promoCodeRedemptionId) { + await this.finishPromoPayment(req, res, data, true); + + return; + } + let businessOperation; let workspace; let tariffPlan; @@ -487,7 +508,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 +522,12 @@ subscription id: ${body.SubscriptionId}`; return; } + if (data.promoCodeRedemptionId) { + await this.finishPromoPayment(req, res, data, false); + + return; + } + let businessOperation; let workspace; let user; @@ -779,6 +806,366 @@ status: ${body.Status}` HawkCatcher.send(new Error(errorText), backtrace); } + /** + * Require the accounts database for promo reservations. + * + * @returns accounts database for promo reservations + */ + 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 payment details for reservation validation + */ + private getPromoPayment(body: CheckRequest | PayRequest | FailRequest, data: PaymentData): PromoPayment { + if (!data.promoCodeRedemptionId || typeof data.paymentAmount !== 'number' || data.isCardLinkOperation) { + throw new Error('PROMO_CODE_INVALID_PAYMENT'); + } + + return { + promoCodeRedemptionId: data.promoCodeRedemptionId, + workspaceId: data.workspaceId, + userId: data.userId, + tariffPlanId: data.tariffPlanId, + paymentAmount: data.paymentAmount, + transactionId: body.TransactionId?.toString() || '', + amount: Number(body.Amount), + currency: body.Currency, + }; + } + + /** + * Bind a reservation and create one pending operation for repeated checks. + * + * @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'); + } + + const reservation = await getPromoCodeQuote(db, payment); + + /** + * Create the operation before binding so a failed write cannot strand quota. + */ + await db.collection('businessOperations').updateOne({ + _id: reservation._id, + 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, + }); + + await bindPromoCodeReservation(db, payment); + + 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 reservation, not just a transaction id. + * + * @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.promoCodeRedemptionId)) || + 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 bound 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); + 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.promoCodeRedemptionId); + 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 completePromoCodeReservation(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 released = await releasePromoCodeReservation(db, payment); + + if (!released) { + const reservation = await db.collection('promoCodeRedemptions').findOne({ + _id: new ObjectId(payment.promoCodeRedemptionId), + }); + + if (reservation?.status === 'used') { + 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 +1181,25 @@ 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 ('promoCodeRedemptionId' in verifiedData && verifiedData.promoCodeRedemptionId) { + /** + * 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, + promoCodeRedemptionId: undefined, + paymentAmount: undefined, }; } @@ -855,8 +1257,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 +1268,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/lifecycle.ts b/src/billing/promoCodes/lifecycle.ts new file mode 100644 index 00000000..187cde35 --- /dev/null +++ b/src/billing/promoCodes/lifecycle.ts @@ -0,0 +1,258 @@ +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; +import type { PromoCodeRedemption } from './redemptions'; + +export interface PromoPayment { + promoCodeRedemptionId: string; + userId: string; + workspaceId: string; + tariffPlanId: string; + paymentAmount: number; + transactionId: string; + amount: number; + currency: string; +} + +/** + * Validate callback fields before querying MongoDB, including already-deleted retries. + * + * @param payment - signed payment identity and provider callback fields + */ +function validatePayment(payment: PromoPayment): void { + const ids = [payment.promoCodeRedemptionId, 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.paymentAmount) || payment.paymentAmount < 1 || + !Number.isFinite(payment.amount) || payment.amount < 1 || + payment.paymentAmount !== payment.amount) { + throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); + } +} + +/** + * Load and validate a reservation without requiring it to be bound yet. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @returns matching reservation, or null if it has been deleted + */ +async function loadReservation(db: Db, payment: PromoPayment): Promise { + validatePayment(payment); + + const reservation = await db.collection('promoCodeRedemptions').findOne({ + _id: new ObjectId(payment.promoCodeRedemptionId), + }); + + if (!reservation) { + return null; + } + + if (!reservation.userId.equals(new ObjectId(payment.userId)) || + !reservation.workspaceId.equals(new ObjectId(payment.workspaceId)) || + !reservation.tariffPlanId.equals(new ObjectId(payment.tariffPlanId)) || + (reservation.status !== 'reserved' && reservation.status !== 'used')) { + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); + } + + if (!Number.isFinite(reservation.finalAmount) || reservation.finalAmount < 1 || + reservation.finalAmount !== payment.paymentAmount) { + throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); + } + + if (reservation.transactionId !== undefined && reservation.transactionId !== payment.transactionId) { + throw new UserInputError('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); + } + + return reservation; +} + +/** + * Validate a quote before creating a pending operation, without binding the reservation. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @param now - quote validation time + * @returns unexpired reservation or an already-used duplicate for the same transaction + */ +export async function getPromoCodeQuote( + db: Db, + payment: PromoPayment, + now = new Date() +): Promise { + const reservation = await loadReservation(db, payment); + + if (!reservation) { + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); + } + + if (reservation.status === 'used' && reservation.transactionId === payment.transactionId) { + return reservation; + } + + if (reservation.status !== 'reserved' || !(reservation.expiresAt instanceof Date) || + !(reservation.expiresAt > now)) { + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); + } + + return reservation; +} + +/** + * Require the exact bound transaction, but allow late callbacks and used-record retries. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @returns matching bound reservation + */ +export async function getPromoCodeReservation(db: Db, payment: PromoPayment): Promise { + const reservation = await loadReservation(db, payment); + + if (!reservation || reservation.transactionId !== payment.transactionId) { + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); + } + + return reservation; +} + +/** + * Bind an unexpired reservation atomically; a used record is returned only for the same transaction. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @param now - binding time + * @returns bound reservation, including an already-used duplicate + */ +export async function bindPromoCodeReservation( + db: Db, + payment: PromoPayment, + now = new Date() +): Promise { + const reservation = await getPromoCodeQuote(db, payment, now); + + if (reservation.status === 'used') { + return reservation; + } + + const boundReservation = await db.collection('promoCodeRedemptions').findOneAndUpdate({ + _id: reservation._id, + status: 'reserved', + expiresAt: { $gt: now }, + $or: [ + { transactionId: { $exists: false } }, + { transactionId: payment.transactionId }, + ], + }, { + $set: { transactionId: payment.transactionId }, + }, { + includeResultMetadata: false, + returnDocument: 'after', + }); + + if (boundReservation) { + return boundReservation; + } + + const currentReservation = await getPromoCodeReservation(db, payment); + + if (currentReservation.status === 'used') { + return currentReservation; + } + + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); +} + +/** + * Consume a bound reservation once, even when completion callbacks race. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @param now - successful payment time + * @returns true only for the callback that changes reserved to used + */ +export async function completePromoCodeReservation(db: Db, payment: PromoPayment, now = new Date()): Promise { + const reservation = await getPromoCodeReservation(db, payment); + + if (reservation.status === 'used') { + return false; + } + + const result = await db.collection('promoCodeRedemptions').updateOne({ + _id: reservation._id, + status: 'reserved', + transactionId: payment.transactionId, + }, { + $set: { + status: 'used', + usedAt: now, + }, + $unset: { expiresAt: '' }, + }); + + if (result.modifiedCount === 1) { + return true; + } + + const currentReservation = await getPromoCodeReservation(db, payment); + + if (currentReservation.status === 'used') { + return false; + } + + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); +} + +/** + * Release only a reserved record bound to this transaction; missing and used retries are no-ops. + * + * @param db - accounts database + * @param payment - signed payment identity and provider callback fields + * @returns whether the reservation was deleted + */ +export async function releasePromoCodeReservation(db: Db, payment: PromoPayment): Promise { + const reservation = await loadReservation(db, payment); + + if (!reservation) { + return false; + } + + if (reservation.transactionId !== payment.transactionId) { + throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); + } + + if (reservation.status === 'used') { + return false; + } + + const result = await db.collection('promoCodeRedemptions').deleteOne({ + _id: reservation._id, + status: 'reserved', + transactionId: payment.transactionId, + }); + + return result.deletedCount === 1; +} + +/** + * Free expired, unbound reservations only; provider-bound records must await a payment outcome. + * + * @param db - accounts database + * @param promoCodeId - code whose expired reservations should be released + * @param now - cleanup time, including the expiry boundary + * @returns number of deleted reservations + */ +export async function releaseExpiredPromoCodeReservations(db: Db, promoCodeId: ObjectId, now = new Date()): Promise { + const result = await db.collection('promoCodeRedemptions').deleteMany({ + promoCodeId, + status: 'reserved', + expiresAt: { $lte: now }, + transactionId: { $exists: false }, + }); + + return result.deletedCount; +} diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index e284ddc9..b0aae8c1 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -2,6 +2,7 @@ import type { Db } from 'mongodb'; import { MongoServerError, ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; import { calculateDiscountedAmount, findActivePromoCode } from './index'; +import { releaseExpiredPromoCodeReservations } from './lifecycle'; const RESERVATION_DURATION_MS = 30 * 60 * 1000; @@ -75,6 +76,9 @@ export async function reservePromoCode( const promoCode = await findActivePromoCode(db, input.promoCode, now); const finalAmount = calculateDiscountedAmount(input.originalAmount, promoCode.discountPercent); const redemptions = db.collection('promoCodeRedemptions'); + + await releaseExpiredPromoCodeReservations(db, promoCode._id, now); + const existingUseQuery = { promoCodeId: promoCode._id, $or: [ diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index fb554dd9..748668fa 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -60,4 +60,16 @@ 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 reservation identifier + */ + promoCodeRedemptionId?: string; + /** + * Signed start date for future full-price payments + */ + nextPaymentDate?: string; } diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index d5d96873..dd330951 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 { reservePromoCode } 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,36 @@ export default { isCardLinkOperation = true; } + let paymentAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : plan.monthlyCharge; + let promoCodeRedemptionId: string | undefined; + let reservationExpiresAt: Date | 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'); + } + + const reservation = await reservePromoCode(databases.hawk, { + promoCode, + userId: new ObjectId(user.id), + workspaceId: workspace._id, + tariffPlanId: plan._id, + originalAmount: plan.monthlyCharge, + }, now); + + paymentAmount = reservation.finalAmount; + promoCodeRedemptionId = reservation._id.toString(); + reservationExpiresAt = reservation.expiresAt; + } + /** * Calculate next payment date */ @@ -151,9 +187,15 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), + ...(promoCodeRedemptionId + ? { + paymentAmount, + promoCodeRedemptionId, + } + : {}), }; - const checksum = await checksumService.generateChecksum(checksumData); + const checksum = await checksumService.generateChecksum(checksumData, reservationExpiresAt); /** * Send info to Telegram (non-blocking) @@ -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..56bc96e0 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,4 +1,4 @@ -import { PlanProlongationPayload } from '@hawk.so/types'; + import jwt, { Secret } from 'jsonwebtoken'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -24,6 +24,14 @@ interface PlanPurchaseChecksumData { * Next payment date */ nextPaymentDate: string; + /** + * Discounted amount for the current payment in rubles + */ + paymentAmount?: number; + /** + * Reservation that authorizes the discounted payment + */ + promoCodeRedemptionId?: string; } interface CardLinkChecksumData { @@ -53,12 +61,26 @@ class ChecksumService { * Generates checksum for processing billing requests * * @param data - data for processing billing request + * @param expiresAt - optional reservation deadline; never extends the default lifetime */ - public async generateChecksum(data: ChecksumData): Promise { + public async generateChecksum(data: ChecksumData, expiresAt?: Date): Promise { + const defaultLifetimeSeconds = 30 * 60; + let expiresIn = defaultLifetimeSeconds; + + if (expiresAt) { + const nowSeconds = Math.floor(Date.now() / 1000); + + expiresIn = Math.min(defaultLifetimeSeconds, Math.floor(expiresAt.getTime() / 1000) - nowSeconds); + + if (!Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('PROMO_CODE_RESERVATION_EXPIRED'); + } + } + return jwt.sign( data, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, - { expiresIn: '30m' } + { expiresIn } ); } @@ -66,9 +88,18 @@ class ChecksumService { * Parses checksum from request data and returns data from it * * @param checksum - checksum to parse + * @param allowExpiredPromo - allow a late callback for an already-bound 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 = 'promoCodeRedemptionId' in payload && Boolean(payload.promoCodeRedemptionId); + + if (allowExpiredPromo && !isPromoPayment) { + jwt.verify(checksum, secret); + } if ('isCardLinkOperation' in payload) { return { @@ -84,6 +115,12 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, + ...(payload.promoCodeRedemptionId + ? { + paymentAmount: payload.paymentAmount, + promoCodeRedemptionId: payload.promoCodeRedemptionId, + } + : {}), }; } } 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/lifecycle.test.ts b/test/services/promoCodes/lifecycle.test.ts new file mode 100644 index 00000000..a1f1e246 --- /dev/null +++ b/test/services/promoCodes/lifecycle.test.ts @@ -0,0 +1,358 @@ +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; +import { UserInputError } from 'apollo-server-express'; +import { + bindPromoCodeReservation, + getPromoCodeQuote, + getPromoCodeReservation, + completePromoCodeReservation, + releasePromoCodeReservation, + releaseExpiredPromoCodeReservations +} from '../../../src/billing/promoCodes/lifecycle'; +import type { PromoPayment } from '../../../src/billing/promoCodes/lifecycle'; +import type { PromoCodeRedemption } from '../../../src/billing/promoCodes/redemptions'; + +const NOW = new Date('2026-10-13T12:00:00.000Z'); +const DEADLINE = new Date(NOW.getTime() + 30 * 60 * 1000); + +describe('Promo code reservation lifecycle', () => { + const findOne = jest.fn(); + const findOneAndUpdate = jest.fn(); + const updateOne = jest.fn(); + const deleteOne = jest.fn(); + const deleteMany = jest.fn(); + const collection = jest.fn(); + const db = { collection } as unknown as Db; + let reservation: PromoCodeRedemption; + let payment: PromoPayment; + + beforeEach(() => { + jest.resetAllMocks(); + reservation = { + _id: new ObjectId(), + promoCodeId: new ObjectId(), + userId: new ObjectId(), + workspaceId: new ObjectId(), + tariffPlanId: new ObjectId(), + status: 'reserved', + originalAmount: 100, + finalAmount: 80, + discountPercent: 20, + createdAt: NOW, + expiresAt: DEADLINE, + transactionId: 'payment-123', + }; + payment = { + promoCodeRedemptionId: reservation._id.toHexString(), + userId: reservation.userId.toHexString(), + workspaceId: reservation.workspaceId.toHexString(), + tariffPlanId: reservation.tariffPlanId.toHexString(), + paymentAmount: 80, + transactionId: 'payment-123', + amount: 80, + currency: 'RUB', + }; + collection.mockReturnValue({ + findOne, + findOneAndUpdate, + updateOne, + deleteOne, + deleteMany, + }); + findOne.mockResolvedValue(reservation); + findOneAndUpdate.mockResolvedValue(reservation); + updateOne.mockResolvedValue({ modifiedCount: 1 }); + deleteOne.mockResolvedValue({ deletedCount: 1 }); + deleteMany.mockResolvedValue({ deletedCount: 2 }); + }); + + test.each([ + ['invalid redemption id', { promoCodeRedemptionId: 'g'.repeat(24) } ], + ['null user id', { userId: null } ], + ['missing workspace id', { workspaceId: undefined } ], + ['non-string tariff id', { tariffPlanId: 123 } ], + ['wrong currency', { currency: 'USD' } ], + ['blank transaction id', { transactionId: ' ' } ], + ] as const)('should reject %s before querying MongoDB', async (_, fields) => { + Object.assign(payment, fields); + + const result = getPromoCodeReservation(db, payment); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_PAYMENT'); + expect(collection).not.toHaveBeenCalled(); + }); + + test.each([ + ['changed signed amount', 81, 80, 80], + ['changed callback amount', 80, 81, 80], + ['sub-ruble payment', 0.99, 0.99, 0.99], + ['non-finite callback amount', 80, NaN, 80], + ['changed stored amount', 80, 80, 79], + ['non-finite stored amount', 80, 80, Infinity], + ] as const)('should reject %s without writing', async (_, signedAmount, callbackAmount, finalAmount) => { + payment.paymentAmount = signedAmount; + payment.amount = callbackAmount; + reservation.finalAmount = finalAmount; + + const result = getPromoCodeReservation(db, payment); + + await expect(result).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); + + expect(findOneAndUpdate).not.toHaveBeenCalled(); + expect(updateOne).not.toHaveBeenCalled(); + expect(deleteOne).not.toHaveBeenCalled(); + }); + + test.each(['userId', 'workspaceId', 'tariffPlanId'] as const)( + 'should reject a reservation belonging to another %s', async field => { + payment[field] = new ObjectId().toHexString(); + + const result = getPromoCodeReservation(db, payment); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); + } + ); + + test.each(['missing', 'unbound', 'unsupported status'])( + 'should reject a %s reservation with a UserInputError', async state => { + findOne.mockResolvedValue(state === 'missing' ? null : { + ...reservation, + transactionId: state === 'unbound' ? undefined : payment.transactionId, + status: state === 'unsupported status' ? 'released' : 'reserved', + }); + + const result = getPromoCodeReservation(db, payment); + + await expect(result).rejects.toBeInstanceOf(UserInputError); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); + } + ); + + test('should load an exact one-ruble payment by ObjectId for a late bound callback', async () => { + reservation.finalAmount = payment.paymentAmount = payment.amount = 1; + reservation.expiresAt = NOW; + payment.userId = payment.userId.toUpperCase(); + + const result = await getPromoCodeReservation(db, payment); + + expect(result).toBe(reservation); + expect(collection).toHaveBeenCalledWith('promoCodeRedemptions'); + expect(findOne).toHaveBeenCalledWith({ _id: reservation._id }); + }); + + test('should require the exact bound transaction without trimming', async () => { + payment.transactionId = ' payment-123 '; + + const result = getPromoCodeReservation(db, payment); + + await expect(result).rejects.toThrow('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); + }); + + test.each(['unbound', 'used'] as const)('should quote a matching %s reservation without writes', async state => { + if (state === 'unbound') { + delete reservation.transactionId; + } else { + reservation.status = 'used'; + delete reservation.expiresAt; + } + + const result = await getPromoCodeQuote(db, payment, NOW); + + expect(result).toBe(reservation); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + expect(updateOne).not.toHaveBeenCalled(); + expect(deleteOne).not.toHaveBeenCalled(); + expect(deleteMany).not.toHaveBeenCalled(); + }); + + test.each([NOW, undefined])('should reject unbound quote expiry %j', async expiresAt => { + delete reservation.transactionId; + reservation.expiresAt = expiresAt; + + const result = getPromoCodeQuote(db, payment, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('should atomically bind an unexpired reservation and retry without extending expiry', async () => { + delete reservation.transactionId; + const bound = { + ...reservation, + transactionId: payment.transactionId, + }; + + findOne.mockResolvedValueOnce(reservation).mockResolvedValue(bound); + findOneAndUpdate.mockResolvedValue(bound); + + const first = await bindPromoCodeReservation(db, payment, NOW); + const retry = await bindPromoCodeReservation(db, payment, NOW); + + expect(first).toBe(bound); + expect(retry).toBe(bound); + expect(bound.expiresAt).toBe(DEADLINE); + expect(findOne).toHaveBeenCalledTimes(2); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + expect(findOneAndUpdate).toHaveBeenCalledWith({ + _id: reservation._id, + status: 'reserved', + expiresAt: { $gt: NOW }, + $or: [ { transactionId: { $exists: false } }, { transactionId: payment.transactionId } ], + }, { + $set: { transactionId: payment.transactionId }, + }, { + includeResultMetadata: false, + returnDocument: 'after', + }); + }); + + test('should never rebind a used reservation from another transaction', async () => { + reservation.status = 'used'; + reservation.transactionId = 'another-payment'; + + const result = bindPromoCodeReservation(db, payment, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + test.each([ + ['reserved', 'PROMO_CODE_INVALID_RESERVATION'], + ['deleted', 'PROMO_CODE_INVALID_RESERVATION'], + ['foreign transaction', 'PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'], + ])('should reject a conditional bind miss with a %s record', async (state, error) => { + const current = state === 'deleted' ? null : { + ...reservation, + transactionId: state === 'foreign transaction' ? 'another-payment' : payment.transactionId, + }; + + delete reservation.transactionId; + findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce(current); + findOneAndUpdate.mockResolvedValue(null); + + const result = bindPromoCodeReservation(db, payment, NOW); + + await expect(result).rejects.toThrow(error); + expect(findOne).toHaveBeenCalledTimes(2); + expect(findOneAndUpdate).toHaveBeenCalledTimes(1); + }); + + test('should return a matching reservation completed concurrently during binding', async () => { + const used = { + ...reservation, + status: 'used', + }; + + findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce(used); + findOneAndUpdate.mockResolvedValue(null); + + const result = await bindPromoCodeReservation(db, payment, NOW); + + expect(result).toBe(used); + expect(findOneAndUpdate).toHaveBeenCalledTimes(1); + }); + + test('should atomically complete a late bound payment and remove its expiry', async () => { + reservation.expiresAt = NOW; + + const result = await completePromoCodeReservation(db, payment, NOW); + + expect(result).toBe(true); + expect(updateOne).toHaveBeenCalledWith({ + _id: reservation._id, + status: 'reserved', + transactionId: payment.transactionId, + }, { + $set: { + status: 'used', + usedAt: NOW, + }, + $unset: { expiresAt: '' }, + }); + }); + + test('should return false for an already completed matching payment', async () => { + reservation.status = 'used'; + delete reservation.expiresAt; + + const result = await completePromoCodeReservation(db, payment, NOW); + + expect(result).toBe(false); + expect(updateOne).not.toHaveBeenCalled(); + }); + + test('should complete only once when matching callbacks race', async () => { + findOne.mockResolvedValueOnce({ ...reservation }).mockResolvedValueOnce({ ...reservation }) + .mockResolvedValue({ + ...reservation, + status: 'used', + }); + updateOne.mockResolvedValueOnce({ modifiedCount: 1 }).mockResolvedValueOnce({ modifiedCount: 0 }); + + const results = await Promise.all([ + completePromoCodeReservation(db, payment, NOW), + completePromoCodeReservation(db, payment, NOW), + ]); + + expect(results).toEqual([true, false]); + expect(updateOne).toHaveBeenCalledTimes(2); + expect(findOne).toHaveBeenCalledTimes(3); + }); + + test.each([ + ['reserved', 'PROMO_CODE_INVALID_RESERVATION'], + ['used by another transaction', 'PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'], + ])('should reject a conditional completion miss with a record %s', async (state, error) => { + findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce({ + ...reservation, + status: state === 'reserved' ? 'reserved' : 'used', + transactionId: state === 'reserved' ? payment.transactionId : 'another-payment', + }); + updateOne.mockResolvedValue({ modifiedCount: 0 }); + + const result = completePromoCodeReservation(db, payment, NOW); + + await expect(result).rejects.toThrow(error); + expect(updateOne).toHaveBeenCalledTimes(1); + }); + + test.each(['missing', 'used'])('should safely release a %s reservation without deleting', async state => { + findOne.mockResolvedValue(state === 'missing' ? null : { + ...reservation, + status: 'used', + }); + + const result = await releasePromoCodeReservation(db, payment); + + expect(result).toBe(false); + expect(deleteOne).not.toHaveBeenCalled(); + }); + + test.each([1, 0])('should conditionally release a bound reservation with deletedCount %i', async deletedCount => { + reservation.expiresAt = NOW; + deleteOne.mockResolvedValue({ deletedCount }); + + const result = await releasePromoCodeReservation(db, payment); + + expect(result).toBe(deletedCount === 1); + expect(deleteOne).toHaveBeenCalledWith({ + _id: reservation._id, + status: 'reserved', + transactionId: payment.transactionId, + }); + }); + + test('should delete only expired unbound reserved records for the requested code', async () => { + const promoCodeId = reservation.promoCodeId; + + const result = await releaseExpiredPromoCodeReservations(db, promoCodeId, NOW); + + expect(result).toBe(2); + expect(deleteMany).toHaveBeenCalledWith({ + promoCodeId, + status: 'reserved', + expiresAt: { $lte: NOW }, + transactionId: { $exists: false }, + }); + }); +}); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index 1045b96d..7cdede6b 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -15,6 +15,7 @@ describe('Promo code reservation', () => { const findRedemption = jest.fn(); const countDocuments = jest.fn(); const insertOne = jest.fn(); + const deleteMany = jest.fn(); const collection = jest.fn(); const db = { collection } as unknown as Db; let promoCode: PromoCode; @@ -60,6 +61,7 @@ describe('Promo code reservation', () => { findOne: findRedemption, countDocuments, insertOne, + deleteMany, }; } @@ -69,6 +71,7 @@ describe('Promo code reservation', () => { findRedemption.mockResolvedValue(null); countDocuments.mockResolvedValue(0); insertOne.mockResolvedValue({ acknowledged: true }); + deleteMany.mockResolvedValue({ deletedCount: 0 }); }); test('should store the user, workspace, plan and discounted price in a reservation', async () => { From a2e153633fda234a12e8e94c8d20779ddec00982 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 13 Oct 2026 18:38:45 +0300 Subject: [PATCH 4/6] chore --- src/billing/promoCodes/index.ts | 62 ++++------- src/billing/promoCodes/redemptions.ts | 10 +- src/utils/checksumService.ts | 4 +- test/services/promoCodes/index.test.ts | 107 +++++++------------ test/services/promoCodes/redemptions.test.ts | 21 +++- 5 files changed, 81 insertions(+), 123 deletions(-) diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index 01ea3336..5f2d0b97 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -1,4 +1,5 @@ -import type { Db, ObjectId } from 'mongodb'; +import type { Db } from 'mongodb'; +import { ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; export interface PromoCode { @@ -7,9 +8,8 @@ export interface PromoCode { discountPercent: number; isActive: boolean; createdAt: Date; - validFrom?: Date; - validUntil?: Date; maxUses?: number; + planIds?: ObjectId[]; } /** @@ -20,7 +20,7 @@ export interface PromoCode { */ export function normalizePromoCode(code: string): string { if (typeof code !== 'string' || !code.trim()) { - throw new UserInputError('PROMO_CODE_NOT_FOUND'); + throw new UserInputError('PROMO_CODE_INVALID'); } return code.trim().toUpperCase(); @@ -47,7 +47,7 @@ export function getMaxUses(value: unknown): number | undefined { */ function validateDiscountPercent(discountPercent: number): void { if (!Number.isFinite(discountPercent) || discountPercent < 1 || discountPercent > 99) { - throw new UserInputError('PROMO_CODE_INVALID_DISCOUNT'); + throw new UserInputError('PROMO_CODE_INVALID'); } } @@ -72,54 +72,30 @@ export function calculateDiscountedAmount(originalAmount: number, discountPercen } /** - * Reject malformed dates in manually configured promo codes. - * - * @param date - optional BSON date stored in the promo code document - */ -function validatePromoDate(date: Date | undefined): void { - if (date !== undefined && (!(date instanceof Date) || !Number.isFinite(date.getTime()))) { - throw new UserInputError('PROMO_CODE_INVALID_DATES'); - } -} - -/** - * Validate the code configuration without reserving or consuming a use. - * The validUntil boundary is exclusive. Usage limits are enforced at reservation. + * Missing planIds means all plans; a present array allows only its listed plans. + * Usage limits are enforced when reserving the payment. * * @param db - accounts database containing promoCodes * @param code - user-provided code - * @param now - validation time + * @param tariffPlanId - selected plan + * @returns validated promo code */ -export async function findActivePromoCode(db: Db, code: string, now = new Date()): Promise { - const normalizedCode = normalizePromoCode(code); +export async function findActivePromoCode(db: Db, code: string, tariffPlanId: ObjectId): Promise { const promoCode = await db.collection('promoCodes').findOne({ - value: normalizedCode, + value: normalizePromoCode(code), }); - if (!promoCode) { - throw new UserInputError('PROMO_CODE_NOT_FOUND'); - } - - if (promoCode.isActive !== true) { - throw new UserInputError('PROMO_CODE_INACTIVE'); + if (!promoCode || promoCode.isActive !== true) { + throw new UserInputError('PROMO_CODE_INVALID'); } - const { discountPercent, validFrom, validUntil } = promoCode; - - validateDiscountPercent(discountPercent); - validatePromoDate(validFrom); - validatePromoDate(validUntil); - - if (validFrom && validUntil && validFrom >= validUntil) { - throw new UserInputError('PROMO_CODE_INVALID_DATES'); - } - - if (validFrom && now < validFrom) { - throw new UserInputError('PROMO_CODE_NOT_STARTED'); - } + validateDiscountPercent(promoCode.discountPercent); - if (validUntil && now >= validUntil) { - throw new UserInputError('PROMO_CODE_EXPIRED'); + 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 { diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index b0aae8c1..499eb22d 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -73,7 +73,7 @@ export async function reservePromoCode( input: ReservePromoCodeInput, now = new Date() ): Promise { - const promoCode = await findActivePromoCode(db, input.promoCode, now); + const promoCode = await findActivePromoCode(db, input.promoCode, input.tariffPlanId); const finalAmount = calculateDiscountedAmount(input.originalAmount, promoCode.discountPercent); const redemptions = db.collection('promoCodeRedemptions'); @@ -104,15 +104,11 @@ export async function reservePromoCode( * Reservations keep occupying quota until explicitly released, not by TTL. */ if (occupiedUses + 1 > promoCode.maxUses) { - throw new UserInputError('PROMO_CODE_LIMIT_REACHED'); + throw new UserInputError('PROMO_CODE_INVALID'); } } - const reservationDeadline = now.getTime() + RESERVATION_DURATION_MS; - const expiresAt = new Date(Math.min( - reservationDeadline, - promoCode.validUntil?.getTime() ?? reservationDeadline - )); + const expiresAt = new Date(now.getTime() + RESERVATION_DURATION_MS); const reservation: PromoCodeRedemption = { _id: new ObjectId(), promoCodeId: promoCode._id, diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 56bc96e0..c5974c60 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,5 +1,5 @@ - import jwt, { Secret } from 'jsonwebtoken'; +import { UserInputError } from 'apollo-server-express'; export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; @@ -73,7 +73,7 @@ class ChecksumService { expiresIn = Math.min(defaultLifetimeSeconds, Math.floor(expiresAt.getTime() / 1000) - nowSeconds); if (!Number.isFinite(expiresIn) || expiresIn <= 0) { - throw new Error('PROMO_CODE_RESERVATION_EXPIRED'); + throw new UserInputError('PROMO_CODE_INVALID'); } } diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts index 508bc0a5..21a8312c 100644 --- a/test/services/promoCodes/index.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -20,28 +20,22 @@ describe('Promo code normalization', () => { }); test.each(['', ' '])('should reject an empty code: %j', code => { - const invalidCode = code; + const action = (): string => normalizePromoCode(code); - const action = (): string => normalizePromoCode(invalidCode); - - expect(action).toThrow('PROMO_CODE_NOT_FOUND'); + expect(action).toThrow('PROMO_CODE_INVALID'); }); }); describe('Promo code usage limit', () => { test.each([0, 1, 10])('should preserve numeric limit %j', value => { - const configuredLimit = value; - - const result = getMaxUses(configuredLimit); + 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 configuredLimit = value; - - const result = getMaxUses(configuredLimit); + const result = getMaxUses(value); expect(result).toBeUndefined(); } @@ -58,10 +52,7 @@ describe('Discounted payment amount', () => { [200, 99, 2], [100, 1, 99], ])('should discount %j RUB by %j percent to %j RUB', (amount, percent, expected) => { - const originalAmount = amount; - const discountPercent = percent; - - const result = calculateDiscountedAmount(originalAmount, discountPercent); + const result = calculateDiscountedAmount(amount, percent); expect(result).toBe(expected); }); @@ -79,7 +70,7 @@ describe('Discounted payment amount', () => { const action = (): number => calculateDiscountedAmount(originalAmount, percent); - expect(action).toThrow('PROMO_CODE_INVALID_DISCOUNT'); + expect(action).toThrow('PROMO_CODE_INVALID'); }); }); @@ -88,9 +79,11 @@ describe('Active promo code lookup', () => { 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', @@ -101,10 +94,10 @@ describe('Active promo code lookup', () => { findOne.mockResolvedValue(promoCode); }); - test('should read a normalized code without modifying the database', async () => { + test('should allow any plan when planIds is absent', async () => { const code = ' sale20 '; - const result = await findActivePromoCode(db, code, NOW); + const result = await findActivePromoCode(db, code, planId); expect(result).toEqual(promoCode); expect(collection).toHaveBeenCalledWith('promoCodes'); @@ -114,89 +107,71 @@ describe('Active promo code lookup', () => { test('should reject a code that does not exist', async () => { findOne.mockResolvedValue(null); - const result = findActivePromoCode(db, 'UNKNOWN', NOW); + const result = findActivePromoCode(db, 'UNKNOWN', planId); - await expect(result).rejects.toThrow('PROMO_CODE_NOT_FOUND'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); }); test('should reject an inactive code', async () => { promoCode.isActive = false; - const result = findActivePromoCode(db, 'SALE20', NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_INACTIVE'); - }); - - test('should reject a code before its start date', async () => { - promoCode.validFrom = new Date(NOW.getTime() + 1); - - const result = findActivePromoCode(db, 'SALE20', NOW); + const result = findActivePromoCode(db, 'SALE20', planId); - await expect(result).rejects.toThrow('PROMO_CODE_NOT_STARTED'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); }); - test('should accept a code exactly at its start date', async () => { - promoCode.validFrom = NOW; + test('should allow a plan explicitly listed in planIds', async () => { + promoCode.planIds = [new ObjectId(), planId]; - const result = await findActivePromoCode(db, 'SALE20', NOW); + const result = await findActivePromoCode(db, 'SALE20', planId); expect(result).toEqual(promoCode); }); - test('should reject an expired code', async () => { - promoCode.validUntil = new Date(NOW.getTime() - 1); + test('should reject a plan not listed in planIds', async () => { + promoCode.planIds = [ new ObjectId() ]; - const result = findActivePromoCode(db, 'SALE20', NOW); + const result = findActivePromoCode(db, 'SALE20', planId); - await expect(result).rejects.toThrow('PROMO_CODE_EXPIRED'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); }); - test('should reject a code exactly at its expiration date', async () => { - promoCode.validUntil = NOW; + test('should allow no plans when planIds is empty', async () => { + promoCode.planIds = []; - const result = findActivePromoCode(db, 'SALE20', NOW); + const result = findActivePromoCode(db, 'SALE20', planId); - await expect(result).rejects.toThrow('PROMO_CODE_EXPIRED'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); }); - test('should accept a code within its validity period', async () => { - promoCode.validFrom = new Date(NOW.getTime() - 1); - promoCode.validUntil = new Date(NOW.getTime() + 1); - - const result = await findActivePromoCode(db, 'SALE20', NOW); - - expect(result).toEqual(promoCode); - }); - - test('should reject malformed dates from manually created documents', async () => { - promoCode.validUntil = new Date('invalid'); - - const result = findActivePromoCode(db, 'SALE20', NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DATES'); - }); - - test('should reject a reversed validity period', async () => { - promoCode.validFrom = new Date(NOW.getTime() + 1); - promoCode.validUntil = new Date(NOW.getTime() - 1); + 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', NOW); + const result = findActivePromoCode(db, 'SALE20', planId); - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DATES'); + 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', NOW); + const result = findActivePromoCode(db, 'SALE20', planId); - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_DISCOUNT'); + 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', NOW); + const result = await findActivePromoCode(db, 'SALE20', planId); expect(result).toMatchObject({ maxUses: 0 }); }); @@ -209,7 +184,7 @@ describe('Active promo code lookup', () => { findOne.mockResolvedValue(storedPromoCode); - const result = await findActivePromoCode(db, 'SALE20', NOW); + 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 index 7cdede6b..6b9d3085 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -97,12 +97,23 @@ describe('Promo code reservation', () => { expect(countDocuments).not.toHaveBeenCalled(); }); - test('should not let a reservation outlive the promo code', async () => { - promoCode.validUntil = new Date(NOW.getTime() + 10 * 60 * 1000); + test('should allow a reservation for a listed plan', async () => { + promoCode.planIds = [ input.tariffPlanId ]; const result = await reservePromoCode(db, input, NOW); - expect(result.expiresAt).toEqual(promoCode.validUntil); + expect(result.tariffPlanId).toEqual(input.tariffPlanId); + expect(result.expiresAt).toEqual(RESERVATION_DEADLINE); + }); + + test('should reject an unlisted plan before creating a reservation', async () => { + promoCode.planIds = [ new ObjectId() ]; + + const result = reservePromoCode(db, input, NOW); + + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + expect(insertOne).not.toHaveBeenCalled(); + expect(countDocuments).not.toHaveBeenCalled(); }); test('should reuse an identical reservation without consuming quota again', async () => { @@ -219,7 +230,7 @@ describe('Promo code reservation', () => { const result = reservePromoCode(db, input, NOW); - await expect(result).rejects.toThrow('PROMO_CODE_LIMIT_REACHED'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); expect(insertOne).not.toHaveBeenCalled(); }); @@ -279,7 +290,7 @@ describe('Promo code reservation', () => { const result = reservePromoCode(db, input, NOW); - await expect(result).rejects.toThrow('PROMO_CODE_INACTIVE'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); expect(insertOne).not.toHaveBeenCalled(); }); }); From 68ec2560e312b250a864e809640e67718212f3e4 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 14 Oct 2026 10:34:20 +0300 Subject: [PATCH 5/6] chore --- src/billing/promoCodes/index.ts | 5 ++--- test/services/promoCodes/index.test.ts | 19 +++++++++++++------ test/services/promoCodes/redemptions.test.ts | 6 ++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index 5f2d0b97..36293a65 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -6,8 +6,7 @@ export interface PromoCode { _id: ObjectId; value: string; discountPercent: number; - isActive: boolean; - createdAt: Date; + isDisabled?: boolean; maxUses?: number; planIds?: ObjectId[]; } @@ -85,7 +84,7 @@ export async function findActivePromoCode(db: Db, code: string, tariffPlanId: Ob value: normalizePromoCode(code), }); - if (!promoCode || promoCode.isActive !== true) { + if (!promoCode || promoCode.isDisabled === true) { throw new UserInputError('PROMO_CODE_INVALID'); } diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts index 21a8312c..7c6ab5de 100644 --- a/test/services/promoCodes/index.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -8,8 +8,6 @@ import { PromoCode } from '../../../src/billing/promoCodes'; -const NOW = new Date('2026-10-13T12:00:00.000Z'); - describe('Promo code normalization', () => { test('should trim whitespace and normalize case', () => { const code = ' sale20 '; @@ -88,8 +86,6 @@ describe('Active promo code lookup', () => { _id: new ObjectId(), value: 'SALE20', discountPercent: 20, - isActive: true, - createdAt: NOW, }; findOne.mockResolvedValue(promoCode); }); @@ -112,14 +108,25 @@ describe('Active promo code lookup', () => { await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); }); - test('should reject an inactive code', async () => { - promoCode.isActive = false; + 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]; diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index 6b9d3085..b20044ac 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -28,8 +28,6 @@ describe('Promo code reservation', () => { _id: new ObjectId(), value: 'SALE20', discountPercent: 20, - isActive: true, - createdAt: NOW, }; input = { promoCode: ' sale20 ', @@ -285,8 +283,8 @@ describe('Promo code reservation', () => { await expect(result).rejects.toBe(error); }); - test('should not reserve an inactive promo code', async () => { - promoCode.isActive = false; + test('should not reserve a disabled promo code', async () => { + promoCode.isDisabled = true; const result = reservePromoCode(db, input, NOW); From 51647cbd990624c3f50dfddf2a04649ca9f1306d Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 14 Oct 2026 16:49:20 +0300 Subject: [PATCH 6/6] chore --- src/billing/cloudpayments.ts | 84 +++-- src/billing/promoCodes/index.ts | 2 +- src/billing/promoCodes/lifecycle.ts | 258 ------------- src/billing/promoCodes/redemptions.ts | 205 ++++++----- src/billing/types/paymentData.ts | 7 +- src/resolvers/billingNew.ts | 22 +- src/utils/checksumService.ts | 44 +-- test/services/promoCodes/lifecycle.test.ts | 358 ------------------- test/services/promoCodes/redemptions.test.ts | 275 +++++--------- 9 files changed, 282 insertions(+), 973 deletions(-) delete mode 100644 src/billing/promoCodes/lifecycle.ts delete mode 100644 test/services/promoCodes/lifecycle.test.ts diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 2fc4c33d..aedf0bb1 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -1,13 +1,8 @@ import express from 'express'; import { ObjectId, Db } from 'mongodb'; import { databases } from '../mongo'; -import { - bindPromoCodeReservation, - getPromoCodeQuote, - completePromoCodeReservation, - releasePromoCodeReservation, - PromoPayment -} from './promoCodes/lifecycle'; +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'; @@ -168,7 +163,7 @@ export default class CloudPaymentsWebhooks { return; } - if (data.promoCodeRedemptionId) { + if (data.promoPaymentId) { await this.checkPromoPayment(req, res, data, workspace, plan); return; @@ -265,7 +260,7 @@ export default class CloudPaymentsWebhooks { } } - if (data.promoCodeRedemptionId) { + if (data.promoPaymentId) { await this.finishPromoPayment(req, res, data, true); return; @@ -522,7 +517,7 @@ subscription id: ${body.SubscriptionId}`; return; } - if (data.promoCodeRedemptionId) { + if (data.promoPaymentId) { await this.finishPromoPayment(req, res, data, false); return; @@ -807,9 +802,9 @@ status: ${body.Status}` } /** - * Require the accounts database for promo reservations. + * Require the accounts database for promo payments. * - * @returns accounts database for promo reservations + * @returns accounts database for promo payments */ private getPromoDatabase(): Db { if (!databases.hawk) { @@ -824,15 +819,19 @@ status: ${body.Status}` * * @param body - provider callback * @param data - parsed payment data - * @returns payment details for reservation validation + * @returns signed payment details */ private getPromoPayment(body: CheckRequest | PayRequest | FailRequest, data: PaymentData): PromoPayment { - if (!data.promoCodeRedemptionId || typeof data.paymentAmount !== 'number' || data.isCardLinkOperation) { + 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 { - promoCodeRedemptionId: data.promoCodeRedemptionId, + promoPaymentId: data.promoPaymentId, + promoCodeId: data.promoCodeId, + originalAmount: data.originalAmount, + discountPercent: data.discountPercent, workspaceId: data.workspaceId, userId: data.userId, tariffPlanId: data.tariffPlanId, @@ -844,7 +843,7 @@ status: ${body.Status}` } /** - * Bind a reservation and create one pending operation for repeated checks. + * Check usage and create one pending operation without consuming the code. * * @param req - callback request * @param res - provider response @@ -874,13 +873,18 @@ status: ${body.Status}` throw new Error('PROMO_CODE_INVALID_SUBSCRIPTION'); } - const reservation = await getPromoCodeQuote(db, payment); + 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); - /** - * Create the operation before binding so a failed write cannot strand quota. - */ await db.collection('businessOperations').updateOne({ - _id: reservation._id, + _id: new ObjectId(payment.promoPaymentId), transactionId: payment.transactionId, }, { $setOnInsert: { @@ -900,7 +904,6 @@ status: ${body.Status}` upsert: true, }); - await bindPromoCodeReservation(db, payment); res.json({ code: CheckCodes.SUCCESS }); } catch (error) { @@ -914,7 +917,7 @@ status: ${body.Status}` } /** - * Require the operation created for this reservation, not just a transaction id. + * Require the operation created for this signed payment attempt. * * @param req - callback request * @param payment - validated promo payment @@ -924,7 +927,7 @@ status: ${body.Status}` const operation = await this.getBusinessOperation(req, payment.transactionId); const payload = operation.payload; - if (!operation._id?.equals(new ObjectId(payment.promoCodeRedemptionId)) || + if (!operation._id?.equals(new ObjectId(payment.promoPaymentId)) || operation.type !== BusinessOperationType.WorkspacePlanPurchase || !('tariffPlanId' in payload) || payload.workspaceId.toString() !== payment.workspaceId || @@ -938,7 +941,7 @@ status: ${body.Status}` } /** - * Finish only a bound promo transaction. A short lease serializes duplicate + * 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 @@ -958,6 +961,9 @@ status: ${body.Status}` 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({ @@ -977,7 +983,7 @@ status: ${body.Status}` throw new Error('PROMO_PAYMENT_IN_PROGRESS'); } - operationId = new ObjectId(payment.promoCodeRedemptionId); + operationId = new ObjectId(payment.promoPaymentId); operation = await this.getPromoBusinessOperation(req, payment); if (!succeeded && operation.status === BusinessOperationStatus.Confirmed) { @@ -1013,7 +1019,7 @@ status: ${body.Status}` */ if (operation.status !== BusinessOperationStatus.Confirmed) { await this.updatePromoOperation(db, operationId, token); - await completePromoCodeReservation(db, payment); + await recordPromoCodeUsage(db, payment); await this.updatePromoOperation(db, operationId, token); const ownsBillingPeriod = await applyPromoPlan(db, workspace._id, plan._id, operationId, operation.dtCreated); @@ -1062,18 +1068,15 @@ status: ${body.Status}` } else { if (operation.status !== BusinessOperationStatus.Rejected) { await this.updatePromoOperation(db, operationId, token); - const released = await releasePromoCodeReservation(db, payment); - - if (!released) { - const reservation = await db.collection('promoCodeRedemptions').findOne({ - _id: new ObjectId(payment.promoCodeRedemptionId), - }); + const successfulUse = await db.collection('promoCodeRedemptions').findOne({ + promoCodeId: new ObjectId(payment.promoCodeId), + transactionId: payment.transactionId, + }); - if (reservation?.status === 'used') { - res.json({ code: 0 }); + if (successfulUse) { + res.json({ code: 0 }); - return; - } + return; } await this.updatePromoOperation(db, operationId, token, { status: BusinessOperationStatus.Rejected }); @@ -1184,7 +1187,7 @@ status: ${body.Status}` const isCompletion = req.path === '/pay' || req.path === '/fail'; const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, isCompletion); - if ('promoCodeRedemptionId' in verifiedData && verifiedData.promoCodeRedemptionId) { + if ('promoPaymentId' in verifiedData && verifiedData.promoPaymentId) { /** * Never override signed promo identity or price with unsigned widget data. */ @@ -1198,7 +1201,10 @@ status: ${body.Status}` return { ...verifiedData, ...parsedData, - promoCodeRedemptionId: undefined, + promoPaymentId: undefined, + promoCodeId: undefined, + originalAmount: undefined, + discountPercent: undefined, paymentAmount: undefined, }; } diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index 36293a65..fc994722 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -72,7 +72,7 @@ export function calculateDiscountedAmount(originalAmount: number, discountPercen /** * Missing planIds means all plans; a present array allows only its listed plans. - * Usage limits are enforced when reserving the payment. + * Usage limits count successful payments only. * * @param db - accounts database containing promoCodes * @param code - user-provided code diff --git a/src/billing/promoCodes/lifecycle.ts b/src/billing/promoCodes/lifecycle.ts deleted file mode 100644 index 187cde35..00000000 --- a/src/billing/promoCodes/lifecycle.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { Db } from 'mongodb'; -import { ObjectId } from 'mongodb'; -import { UserInputError } from 'apollo-server-express'; -import type { PromoCodeRedemption } from './redemptions'; - -export interface PromoPayment { - promoCodeRedemptionId: string; - userId: string; - workspaceId: string; - tariffPlanId: string; - paymentAmount: number; - transactionId: string; - amount: number; - currency: string; -} - -/** - * Validate callback fields before querying MongoDB, including already-deleted retries. - * - * @param payment - signed payment identity and provider callback fields - */ -function validatePayment(payment: PromoPayment): void { - const ids = [payment.promoCodeRedemptionId, 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.paymentAmount) || payment.paymentAmount < 1 || - !Number.isFinite(payment.amount) || payment.amount < 1 || - payment.paymentAmount !== payment.amount) { - throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); - } -} - -/** - * Load and validate a reservation without requiring it to be bound yet. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @returns matching reservation, or null if it has been deleted - */ -async function loadReservation(db: Db, payment: PromoPayment): Promise { - validatePayment(payment); - - const reservation = await db.collection('promoCodeRedemptions').findOne({ - _id: new ObjectId(payment.promoCodeRedemptionId), - }); - - if (!reservation) { - return null; - } - - if (!reservation.userId.equals(new ObjectId(payment.userId)) || - !reservation.workspaceId.equals(new ObjectId(payment.workspaceId)) || - !reservation.tariffPlanId.equals(new ObjectId(payment.tariffPlanId)) || - (reservation.status !== 'reserved' && reservation.status !== 'used')) { - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); - } - - if (!Number.isFinite(reservation.finalAmount) || reservation.finalAmount < 1 || - reservation.finalAmount !== payment.paymentAmount) { - throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); - } - - if (reservation.transactionId !== undefined && reservation.transactionId !== payment.transactionId) { - throw new UserInputError('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); - } - - return reservation; -} - -/** - * Validate a quote before creating a pending operation, without binding the reservation. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @param now - quote validation time - * @returns unexpired reservation or an already-used duplicate for the same transaction - */ -export async function getPromoCodeQuote( - db: Db, - payment: PromoPayment, - now = new Date() -): Promise { - const reservation = await loadReservation(db, payment); - - if (!reservation) { - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); - } - - if (reservation.status === 'used' && reservation.transactionId === payment.transactionId) { - return reservation; - } - - if (reservation.status !== 'reserved' || !(reservation.expiresAt instanceof Date) || - !(reservation.expiresAt > now)) { - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); - } - - return reservation; -} - -/** - * Require the exact bound transaction, but allow late callbacks and used-record retries. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @returns matching bound reservation - */ -export async function getPromoCodeReservation(db: Db, payment: PromoPayment): Promise { - const reservation = await loadReservation(db, payment); - - if (!reservation || reservation.transactionId !== payment.transactionId) { - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); - } - - return reservation; -} - -/** - * Bind an unexpired reservation atomically; a used record is returned only for the same transaction. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @param now - binding time - * @returns bound reservation, including an already-used duplicate - */ -export async function bindPromoCodeReservation( - db: Db, - payment: PromoPayment, - now = new Date() -): Promise { - const reservation = await getPromoCodeQuote(db, payment, now); - - if (reservation.status === 'used') { - return reservation; - } - - const boundReservation = await db.collection('promoCodeRedemptions').findOneAndUpdate({ - _id: reservation._id, - status: 'reserved', - expiresAt: { $gt: now }, - $or: [ - { transactionId: { $exists: false } }, - { transactionId: payment.transactionId }, - ], - }, { - $set: { transactionId: payment.transactionId }, - }, { - includeResultMetadata: false, - returnDocument: 'after', - }); - - if (boundReservation) { - return boundReservation; - } - - const currentReservation = await getPromoCodeReservation(db, payment); - - if (currentReservation.status === 'used') { - return currentReservation; - } - - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); -} - -/** - * Consume a bound reservation once, even when completion callbacks race. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @param now - successful payment time - * @returns true only for the callback that changes reserved to used - */ -export async function completePromoCodeReservation(db: Db, payment: PromoPayment, now = new Date()): Promise { - const reservation = await getPromoCodeReservation(db, payment); - - if (reservation.status === 'used') { - return false; - } - - const result = await db.collection('promoCodeRedemptions').updateOne({ - _id: reservation._id, - status: 'reserved', - transactionId: payment.transactionId, - }, { - $set: { - status: 'used', - usedAt: now, - }, - $unset: { expiresAt: '' }, - }); - - if (result.modifiedCount === 1) { - return true; - } - - const currentReservation = await getPromoCodeReservation(db, payment); - - if (currentReservation.status === 'used') { - return false; - } - - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); -} - -/** - * Release only a reserved record bound to this transaction; missing and used retries are no-ops. - * - * @param db - accounts database - * @param payment - signed payment identity and provider callback fields - * @returns whether the reservation was deleted - */ -export async function releasePromoCodeReservation(db: Db, payment: PromoPayment): Promise { - const reservation = await loadReservation(db, payment); - - if (!reservation) { - return false; - } - - if (reservation.transactionId !== payment.transactionId) { - throw new UserInputError('PROMO_CODE_INVALID_RESERVATION'); - } - - if (reservation.status === 'used') { - return false; - } - - const result = await db.collection('promoCodeRedemptions').deleteOne({ - _id: reservation._id, - status: 'reserved', - transactionId: payment.transactionId, - }); - - return result.deletedCount === 1; -} - -/** - * Free expired, unbound reservations only; provider-bound records must await a payment outcome. - * - * @param db - accounts database - * @param promoCodeId - code whose expired reservations should be released - * @param now - cleanup time, including the expiry boundary - * @returns number of deleted reservations - */ -export async function releaseExpiredPromoCodeReservations(db: Db, promoCodeId: ObjectId, now = new Date()): Promise { - const result = await db.collection('promoCodeRedemptions').deleteMany({ - promoCodeId, - status: 'reserved', - expiresAt: { $lte: now }, - transactionId: { $exists: false }, - }); - - return result.deletedCount; -} diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index 499eb22d..8454f33b 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -1,28 +1,24 @@ import type { Db } from 'mongodb'; import { MongoServerError, ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; -import { calculateDiscountedAmount, findActivePromoCode } from './index'; -import { releaseExpiredPromoCodeReservations } from './lifecycle'; +import { calculateDiscountedAmount, findActivePromoCode, getMaxUses } from './index'; -const RESERVATION_DURATION_MS = 30 * 60 * 1000; +export interface PromoCodeQuote { + promoCodeId: ObjectId; + originalAmount: number; + finalAmount: number; + discountPercent: number; +} -export interface PromoCodeRedemption { +export interface PromoCodeRedemption extends PromoCodeQuote { _id: ObjectId; - promoCodeId: ObjectId; userId: ObjectId; workspaceId: ObjectId; tariffPlanId: ObjectId; - status: 'reserved' | 'used'; - originalAmount: number; - finalAmount: number; - discountPercent: number; - createdAt: Date; - expiresAt?: Date; - transactionId?: string; - usedAt?: Date; + transactionId: string; } -export interface ReservePromoCodeInput { +export interface PromoCodeInput { promoCode: string; userId: ObjectId; workspaceId: ObjectId; @@ -30,114 +26,149 @@ export interface ReservePromoCodeInput { 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; +} + /** - * Reuse only an unexpired reservation for the exact same payment preparation. - * A reservation bound to a provider transaction must not start another payment. + * Check successful uses only. Parallel payments may both pass before either succeeds. * - * @param reservation - existing use by this user or workspace - * @param input - requested payment - * @param now - reservation time - * @returns existing reservation, or throws if reuse is not allowed + * @param db - accounts database + * @param promoCodeId - selected code + * @param userId - payer + * @param workspaceId - paid workspace + * @param transactionId - same successful callback retry, if present */ -function reuseReservation( - reservation: PromoCodeRedemption, - input: ReservePromoCodeInput, - now: Date -): PromoCodeRedemption { - const samePayment = reservation.userId.equals(input.userId) && - reservation.workspaceId.equals(input.workspaceId) && - reservation.tariffPlanId.equals(input.tariffPlanId) && - reservation.originalAmount === input.originalAmount; - const canReuse = reservation.status === 'reserved' && - reservation.expiresAt && reservation.expiresAt > now && - !reservation.transactionId; - - if (!samePayment || !canReuse) { +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'); } - return reservation; + 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'); + } } /** - * Reserve a discounted payment without changing the promo code's configured limit. - * Unique indexes enforce the per-user and per-workspace restrictions. + * Calculate a signed quote without writing a usage record. * * @param db - accounts database - * @param input - payment being prepared - * @param now - reservation time - * @returns new reservation or the existing reservation for an identical retry + * @param input - requested payment + * @returns discounted quote */ -export async function reservePromoCode( - db: Db, - input: ReservePromoCodeInput, - now = new Date() -): Promise { +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); - const redemptions = db.collection('promoCodeRedemptions'); - await releaseExpiredPromoCodeReservations(db, promoCode._id, now); + await checkPromoCodeUsage(db, promoCode._id, input.userId, input.workspaceId); - const existingUseQuery = { + return { promoCodeId: promoCode._id, - $or: [ - { userId: input.userId }, - { workspaceId: input.workspaceId }, - ], + originalAmount: input.originalAmount, + finalAmount, + discountPercent: promoCode.discountPercent, }; - const existingReservation = await redemptions.findOne(existingUseQuery); +} - if (existingReservation) { - return reuseReservation(existingReservation, input, now); +/** + * 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'); } - if (promoCode.maxUses !== undefined) { - const occupiedUses = await redemptions.countDocuments({ - promoCodeId: promoCode._id, - status: { $in: ['reserved', 'used'] }, - }); + const expectedAmount = calculateDiscountedAmount(payment.originalAmount, payment.discountPercent); - /** - * MVP trade-off: count and insert are not atomic. Concurrent users/workspaces - * may exceed maxUses; strict global enforcement is intentionally deferred. - * Reservations keep occupying quota until explicitly released, not by TTL. - */ - if (occupiedUses + 1 > promoCode.maxUses) { - throw new UserInputError('PROMO_CODE_INVALID'); - } + 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 expiresAt = new Date(now.getTime() + RESERVATION_DURATION_MS); - const reservation: PromoCodeRedemption = { + const redemptions = db.collection('promoCodeRedemptions'); + const usage: PromoCodeRedemption = { _id: new ObjectId(), - promoCodeId: promoCode._id, - userId: input.userId, - workspaceId: input.workspaceId, - tariffPlanId: input.tariffPlanId, - status: 'reserved', - originalAmount: input.originalAmount, - finalAmount, - discountPercent: promoCode.discountPercent, - createdAt: now, - expiresAt, + 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(reservation); + await redemptions.insertOne(usage); } catch (error) { if (!(error instanceof MongoServerError) || error.code !== 11000) { throw error; } - const concurrentReservation = await redemptions.findOne(existingUseQuery); + const existing = await redemptions.findOne({ + promoCodeId: usage.promoCodeId, + $or: [{ userId: usage.userId }, { workspaceId: usage.workspaceId }], + }); - if (concurrentReservation) { - return reuseReservation(concurrentReservation, input, now); + if (!existing) { + throw error; } - throw error; + if (existing.transactionId !== payment.transactionId) { + console.warn('Promo code was used by a parallel successful payment', payment.transactionId); + } } - - return reservation; } diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 748668fa..b4f22309 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -65,9 +65,12 @@ export interface PaymentData { */ paymentAmount?: number; /** - * Signed promo reservation identifier + * Signed promo payment attempt and price snapshot */ - promoCodeRedemptionId?: string; + promoPaymentId?: string; + promoCodeId?: string; + originalAmount?: number; + discountPercent?: number; /** * Signed start date for future full-price payments */ diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index dd330951..6e2b1c2b 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -10,7 +10,7 @@ import { import checksumService from '../utils/checksumService'; import { ObjectId } from 'mongodb'; import { databases } from '../mongo'; -import { reservePromoCode } from '../billing/promoCodes/redemptions'; +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'; @@ -133,8 +133,7 @@ export default { } let paymentAmount = isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : plan.monthlyCharge; - let promoCodeRedemptionId: string | undefined; - let reservationExpiresAt: Date | undefined; + let promoQuote: PromoCodeQuote | undefined; if (promoCode) { if (isCardLinkOperation) { @@ -149,17 +148,15 @@ export default { throw new Error('Accounts database is not connected'); } - const reservation = await reservePromoCode(databases.hawk, { + promoQuote = await preparePromoCode(databases.hawk, { promoCode, userId: new ObjectId(user.id), workspaceId: workspace._id, tariffPlanId: plan._id, originalAmount: plan.monthlyCharge, - }, now); + }); - paymentAmount = reservation.finalAmount; - promoCodeRedemptionId = reservation._id.toString(); - reservationExpiresAt = reservation.expiresAt; + paymentAmount = promoQuote.finalAmount; } /** @@ -187,15 +184,18 @@ export default { tariffPlanId: plan._id.toString(), shouldSaveCard: Boolean(shouldSaveCard), nextPaymentDate: nextPaymentDate.toISOString(), - ...(promoCodeRedemptionId + ...(promoQuote ? { paymentAmount, - promoCodeRedemptionId, + promoPaymentId: new ObjectId().toString(), + promoCodeId: promoQuote.promoCodeId.toString(), + originalAmount: promoQuote.originalAmount, + discountPercent: promoQuote.discountPercent, } : {}), }; - const checksum = await checksumService.generateChecksum(checksumData, reservationExpiresAt); + const checksum = await checksumService.generateChecksum(checksumData); /** * Send info to Telegram (non-blocking) diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index c5974c60..07f77527 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -29,9 +29,12 @@ interface PlanPurchaseChecksumData { */ paymentAmount?: number; /** - * Reservation that authorizes the discounted payment + * Signed identity and price of the promo payment attempt */ - promoCodeRedemptionId?: string; + promoPaymentId?: string; + promoCodeId?: string; + originalAmount?: number; + discountPercent?: number; } interface CardLinkChecksumData { @@ -61,41 +64,27 @@ class ChecksumService { * Generates checksum for processing billing requests * * @param data - data for processing billing request - * @param expiresAt - optional reservation deadline; never extends the default lifetime */ - public async generateChecksum(data: ChecksumData, expiresAt?: Date): Promise { - const defaultLifetimeSeconds = 30 * 60; - let expiresIn = defaultLifetimeSeconds; - - if (expiresAt) { - const nowSeconds = Math.floor(Date.now() / 1000); - - expiresIn = Math.min(defaultLifetimeSeconds, Math.floor(expiresAt.getTime() / 1000) - nowSeconds); - - if (!Number.isFinite(expiresIn) || expiresIn <= 0) { - throw new UserInputError('PROMO_CODE_INVALID'); - } - } - - return jwt.sign( - data, - process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, - { expiresIn } - ); + public async generateChecksum(data: ChecksumData): Promise { + 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 an already-bound promo payment + * @param allowExpiredPromo - allow a late callback for a checked promo payment */ 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 = 'promoCodeRedemptionId' in payload && Boolean(payload.promoCodeRedemptionId); + 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); @@ -115,10 +104,13 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, - ...(payload.promoCodeRedemptionId + ...(payload.promoPaymentId ? { paymentAmount: payload.paymentAmount, - promoCodeRedemptionId: payload.promoCodeRedemptionId, + promoPaymentId: payload.promoPaymentId, + promoCodeId: payload.promoCodeId, + originalAmount: payload.originalAmount, + discountPercent: payload.discountPercent, } : {}), }; diff --git a/test/services/promoCodes/lifecycle.test.ts b/test/services/promoCodes/lifecycle.test.ts deleted file mode 100644 index a1f1e246..00000000 --- a/test/services/promoCodes/lifecycle.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import type { Db } from 'mongodb'; -import { ObjectId } from 'mongodb'; -import { UserInputError } from 'apollo-server-express'; -import { - bindPromoCodeReservation, - getPromoCodeQuote, - getPromoCodeReservation, - completePromoCodeReservation, - releasePromoCodeReservation, - releaseExpiredPromoCodeReservations -} from '../../../src/billing/promoCodes/lifecycle'; -import type { PromoPayment } from '../../../src/billing/promoCodes/lifecycle'; -import type { PromoCodeRedemption } from '../../../src/billing/promoCodes/redemptions'; - -const NOW = new Date('2026-10-13T12:00:00.000Z'); -const DEADLINE = new Date(NOW.getTime() + 30 * 60 * 1000); - -describe('Promo code reservation lifecycle', () => { - const findOne = jest.fn(); - const findOneAndUpdate = jest.fn(); - const updateOne = jest.fn(); - const deleteOne = jest.fn(); - const deleteMany = jest.fn(); - const collection = jest.fn(); - const db = { collection } as unknown as Db; - let reservation: PromoCodeRedemption; - let payment: PromoPayment; - - beforeEach(() => { - jest.resetAllMocks(); - reservation = { - _id: new ObjectId(), - promoCodeId: new ObjectId(), - userId: new ObjectId(), - workspaceId: new ObjectId(), - tariffPlanId: new ObjectId(), - status: 'reserved', - originalAmount: 100, - finalAmount: 80, - discountPercent: 20, - createdAt: NOW, - expiresAt: DEADLINE, - transactionId: 'payment-123', - }; - payment = { - promoCodeRedemptionId: reservation._id.toHexString(), - userId: reservation.userId.toHexString(), - workspaceId: reservation.workspaceId.toHexString(), - tariffPlanId: reservation.tariffPlanId.toHexString(), - paymentAmount: 80, - transactionId: 'payment-123', - amount: 80, - currency: 'RUB', - }; - collection.mockReturnValue({ - findOne, - findOneAndUpdate, - updateOne, - deleteOne, - deleteMany, - }); - findOne.mockResolvedValue(reservation); - findOneAndUpdate.mockResolvedValue(reservation); - updateOne.mockResolvedValue({ modifiedCount: 1 }); - deleteOne.mockResolvedValue({ deletedCount: 1 }); - deleteMany.mockResolvedValue({ deletedCount: 2 }); - }); - - test.each([ - ['invalid redemption id', { promoCodeRedemptionId: 'g'.repeat(24) } ], - ['null user id', { userId: null } ], - ['missing workspace id', { workspaceId: undefined } ], - ['non-string tariff id', { tariffPlanId: 123 } ], - ['wrong currency', { currency: 'USD' } ], - ['blank transaction id', { transactionId: ' ' } ], - ] as const)('should reject %s before querying MongoDB', async (_, fields) => { - Object.assign(payment, fields); - - const result = getPromoCodeReservation(db, payment); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_PAYMENT'); - expect(collection).not.toHaveBeenCalled(); - }); - - test.each([ - ['changed signed amount', 81, 80, 80], - ['changed callback amount', 80, 81, 80], - ['sub-ruble payment', 0.99, 0.99, 0.99], - ['non-finite callback amount', 80, NaN, 80], - ['changed stored amount', 80, 80, 79], - ['non-finite stored amount', 80, 80, Infinity], - ] as const)('should reject %s without writing', async (_, signedAmount, callbackAmount, finalAmount) => { - payment.paymentAmount = signedAmount; - payment.amount = callbackAmount; - reservation.finalAmount = finalAmount; - - const result = getPromoCodeReservation(db, payment); - - await expect(result).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); - - expect(findOneAndUpdate).not.toHaveBeenCalled(); - expect(updateOne).not.toHaveBeenCalled(); - expect(deleteOne).not.toHaveBeenCalled(); - }); - - test.each(['userId', 'workspaceId', 'tariffPlanId'] as const)( - 'should reject a reservation belonging to another %s', async field => { - payment[field] = new ObjectId().toHexString(); - - const result = getPromoCodeReservation(db, payment); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); - } - ); - - test.each(['missing', 'unbound', 'unsupported status'])( - 'should reject a %s reservation with a UserInputError', async state => { - findOne.mockResolvedValue(state === 'missing' ? null : { - ...reservation, - transactionId: state === 'unbound' ? undefined : payment.transactionId, - status: state === 'unsupported status' ? 'released' : 'reserved', - }); - - const result = getPromoCodeReservation(db, payment); - - await expect(result).rejects.toBeInstanceOf(UserInputError); - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); - } - ); - - test('should load an exact one-ruble payment by ObjectId for a late bound callback', async () => { - reservation.finalAmount = payment.paymentAmount = payment.amount = 1; - reservation.expiresAt = NOW; - payment.userId = payment.userId.toUpperCase(); - - const result = await getPromoCodeReservation(db, payment); - - expect(result).toBe(reservation); - expect(collection).toHaveBeenCalledWith('promoCodeRedemptions'); - expect(findOne).toHaveBeenCalledWith({ _id: reservation._id }); - }); - - test('should require the exact bound transaction without trimming', async () => { - payment.transactionId = ' payment-123 '; - - const result = getPromoCodeReservation(db, payment); - - await expect(result).rejects.toThrow('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); - }); - - test.each(['unbound', 'used'] as const)('should quote a matching %s reservation without writes', async state => { - if (state === 'unbound') { - delete reservation.transactionId; - } else { - reservation.status = 'used'; - delete reservation.expiresAt; - } - - const result = await getPromoCodeQuote(db, payment, NOW); - - expect(result).toBe(reservation); - expect(findOneAndUpdate).not.toHaveBeenCalled(); - expect(updateOne).not.toHaveBeenCalled(); - expect(deleteOne).not.toHaveBeenCalled(); - expect(deleteMany).not.toHaveBeenCalled(); - }); - - test.each([NOW, undefined])('should reject unbound quote expiry %j', async expiresAt => { - delete reservation.transactionId; - reservation.expiresAt = expiresAt; - - const result = getPromoCodeQuote(db, payment, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID_RESERVATION'); - expect(findOneAndUpdate).not.toHaveBeenCalled(); - }); - - test('should atomically bind an unexpired reservation and retry without extending expiry', async () => { - delete reservation.transactionId; - const bound = { - ...reservation, - transactionId: payment.transactionId, - }; - - findOne.mockResolvedValueOnce(reservation).mockResolvedValue(bound); - findOneAndUpdate.mockResolvedValue(bound); - - const first = await bindPromoCodeReservation(db, payment, NOW); - const retry = await bindPromoCodeReservation(db, payment, NOW); - - expect(first).toBe(bound); - expect(retry).toBe(bound); - expect(bound.expiresAt).toBe(DEADLINE); - expect(findOne).toHaveBeenCalledTimes(2); - expect(findOneAndUpdate).toHaveBeenCalledTimes(2); - expect(findOneAndUpdate).toHaveBeenCalledWith({ - _id: reservation._id, - status: 'reserved', - expiresAt: { $gt: NOW }, - $or: [ { transactionId: { $exists: false } }, { transactionId: payment.transactionId } ], - }, { - $set: { transactionId: payment.transactionId }, - }, { - includeResultMetadata: false, - returnDocument: 'after', - }); - }); - - test('should never rebind a used reservation from another transaction', async () => { - reservation.status = 'used'; - reservation.transactionId = 'another-payment'; - - const result = bindPromoCodeReservation(db, payment, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'); - expect(findOneAndUpdate).not.toHaveBeenCalled(); - }); - - test.each([ - ['reserved', 'PROMO_CODE_INVALID_RESERVATION'], - ['deleted', 'PROMO_CODE_INVALID_RESERVATION'], - ['foreign transaction', 'PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'], - ])('should reject a conditional bind miss with a %s record', async (state, error) => { - const current = state === 'deleted' ? null : { - ...reservation, - transactionId: state === 'foreign transaction' ? 'another-payment' : payment.transactionId, - }; - - delete reservation.transactionId; - findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce(current); - findOneAndUpdate.mockResolvedValue(null); - - const result = bindPromoCodeReservation(db, payment, NOW); - - await expect(result).rejects.toThrow(error); - expect(findOne).toHaveBeenCalledTimes(2); - expect(findOneAndUpdate).toHaveBeenCalledTimes(1); - }); - - test('should return a matching reservation completed concurrently during binding', async () => { - const used = { - ...reservation, - status: 'used', - }; - - findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce(used); - findOneAndUpdate.mockResolvedValue(null); - - const result = await bindPromoCodeReservation(db, payment, NOW); - - expect(result).toBe(used); - expect(findOneAndUpdate).toHaveBeenCalledTimes(1); - }); - - test('should atomically complete a late bound payment and remove its expiry', async () => { - reservation.expiresAt = NOW; - - const result = await completePromoCodeReservation(db, payment, NOW); - - expect(result).toBe(true); - expect(updateOne).toHaveBeenCalledWith({ - _id: reservation._id, - status: 'reserved', - transactionId: payment.transactionId, - }, { - $set: { - status: 'used', - usedAt: NOW, - }, - $unset: { expiresAt: '' }, - }); - }); - - test('should return false for an already completed matching payment', async () => { - reservation.status = 'used'; - delete reservation.expiresAt; - - const result = await completePromoCodeReservation(db, payment, NOW); - - expect(result).toBe(false); - expect(updateOne).not.toHaveBeenCalled(); - }); - - test('should complete only once when matching callbacks race', async () => { - findOne.mockResolvedValueOnce({ ...reservation }).mockResolvedValueOnce({ ...reservation }) - .mockResolvedValue({ - ...reservation, - status: 'used', - }); - updateOne.mockResolvedValueOnce({ modifiedCount: 1 }).mockResolvedValueOnce({ modifiedCount: 0 }); - - const results = await Promise.all([ - completePromoCodeReservation(db, payment, NOW), - completePromoCodeReservation(db, payment, NOW), - ]); - - expect(results).toEqual([true, false]); - expect(updateOne).toHaveBeenCalledTimes(2); - expect(findOne).toHaveBeenCalledTimes(3); - }); - - test.each([ - ['reserved', 'PROMO_CODE_INVALID_RESERVATION'], - ['used by another transaction', 'PROMO_CODE_RESERVED_FOR_ANOTHER_PAYMENT'], - ])('should reject a conditional completion miss with a record %s', async (state, error) => { - findOne.mockResolvedValueOnce(reservation).mockResolvedValueOnce({ - ...reservation, - status: state === 'reserved' ? 'reserved' : 'used', - transactionId: state === 'reserved' ? payment.transactionId : 'another-payment', - }); - updateOne.mockResolvedValue({ modifiedCount: 0 }); - - const result = completePromoCodeReservation(db, payment, NOW); - - await expect(result).rejects.toThrow(error); - expect(updateOne).toHaveBeenCalledTimes(1); - }); - - test.each(['missing', 'used'])('should safely release a %s reservation without deleting', async state => { - findOne.mockResolvedValue(state === 'missing' ? null : { - ...reservation, - status: 'used', - }); - - const result = await releasePromoCodeReservation(db, payment); - - expect(result).toBe(false); - expect(deleteOne).not.toHaveBeenCalled(); - }); - - test.each([1, 0])('should conditionally release a bound reservation with deletedCount %i', async deletedCount => { - reservation.expiresAt = NOW; - deleteOne.mockResolvedValue({ deletedCount }); - - const result = await releasePromoCodeReservation(db, payment); - - expect(result).toBe(deletedCount === 1); - expect(deleteOne).toHaveBeenCalledWith({ - _id: reservation._id, - status: 'reserved', - transactionId: payment.transactionId, - }); - }); - - test('should delete only expired unbound reserved records for the requested code', async () => { - const promoCodeId = reservation.promoCodeId; - - const result = await releaseExpiredPromoCodeReservations(db, promoCodeId, NOW); - - expect(result).toBe(2); - expect(deleteMany).toHaveBeenCalledWith({ - promoCodeId, - status: 'reserved', - expiresAt: { $lte: NOW }, - transactionId: { $exists: false }, - }); - }); -}); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index b20044ac..d14ecf09 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -1,34 +1,24 @@ import type { Db } from 'mongodb'; import { MongoServerError, ObjectId } from 'mongodb'; -import { - PromoCodeRedemption, - ReservePromoCodeInput, - reservePromoCode -} from '../../../src/billing/promoCodes/redemptions'; +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'; -const NOW = new Date('2026-10-13T12:00:00.000Z'); -const RESERVATION_DEADLINE = new Date(NOW.getTime() + 30 * 60 * 1000); - -describe('Promo code reservation', () => { +describe('Promo code usage', () => { const findPromoCode = jest.fn(); const findRedemption = jest.fn(); const countDocuments = jest.fn(); const insertOne = jest.fn(); - const deleteMany = jest.fn(); const collection = jest.fn(); const db = { collection } as unknown as Db; let promoCode: PromoCode; - let input: ReservePromoCodeInput; - let existingReservation: PromoCodeRedemption; + let input: PromoCodeInput; + let payment: PromoPayment; + let existingUse: PromoCodeRedemption; beforeEach(() => { jest.resetAllMocks(); - promoCode = { - _id: new ObjectId(), - value: 'SALE20', - discountPercent: 20, - }; + promoCode = { _id: new ObjectId(), value: 'SALE20', discountPercent: 20 }; input = { promoCode: ' sale20 ', userId: new ObjectId(), @@ -36,259 +26,162 @@ describe('Promo code reservation', () => { tariffPlanId: new ObjectId(), originalAmount: 100, }; - existingReservation = { + 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, - status: 'reserved', + transactionId: payment.transactionId, originalAmount: 100, finalAmount: 80, discountPercent: 20, - createdAt: NOW, - expiresAt: RESERVATION_DEADLINE, }; collection.mockImplementation((name: string) => { if (name === 'promoCodes') { return { findOne: findPromoCode }; } - if (name === 'promoCodeRedemptions') { - return { - findOne: findRedemption, - countDocuments, - insertOne, - deleteMany, - }; - } - - throw new Error(`Unexpected collection: ${name}`); + return { findOne: findRedemption, countDocuments, insertOne }; }); findPromoCode.mockResolvedValue(promoCode); findRedemption.mockResolvedValue(null); countDocuments.mockResolvedValue(0); insertOne.mockResolvedValue({ acknowledged: true }); - deleteMany.mockResolvedValue({ deletedCount: 0 }); }); - test('should store the user, workspace, plan and discounted price in a reservation', async () => { - const expectedPrice = 80; + test('should prepare a discounted quote without creating a usage record', async () => { + const expectedAmount = 80; - const result = await reservePromoCode(db, input, NOW); + const result = await preparePromoCode(db, input); - expect(result._id).toBeInstanceOf(ObjectId); - expect(result).toMatchObject({ + expect(result).toEqual({ promoCodeId: promoCode._id, - userId: input.userId, - workspaceId: input.workspaceId, - tariffPlanId: input.tariffPlanId, - status: 'reserved', originalAmount: 100, - finalAmount: expectedPrice, + finalAmount: expectedAmount, discountPercent: 20, - createdAt: NOW, - expiresAt: RESERVATION_DEADLINE, }); - expect(findPromoCode).toHaveBeenCalledWith({ value: 'SALE20' }); - expect(insertOne).toHaveBeenCalledWith(result); - expect(countDocuments).not.toHaveBeenCalled(); - }); - - test('should allow a reservation for a listed plan', async () => { - promoCode.planIds = [ input.tariffPlanId ]; - - const result = await reservePromoCode(db, input, NOW); - - expect(result.tariffPlanId).toEqual(input.tariffPlanId); - expect(result.expiresAt).toEqual(RESERVATION_DEADLINE); - }); - - test('should reject an unlisted plan before creating a reservation', async () => { - promoCode.planIds = [ new ObjectId() ]; - - const result = reservePromoCode(db, input, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); expect(insertOne).not.toHaveBeenCalled(); - expect(countDocuments).not.toHaveBeenCalled(); }); - test('should reuse an identical reservation without consuming quota again', async () => { - promoCode.maxUses = 1; - findRedemption.mockResolvedValue(existingReservation); - countDocuments.mockResolvedValue(1); + test('should allow repeated preparation while no successful use exists', async () => { + const first = await preparePromoCode(db, input); - const result = await reservePromoCode(db, input, NOW); + const second = await preparePromoCode(db, input); - expect(result).toBe(existingReservation); - expect(countDocuments).not.toHaveBeenCalled(); + expect(second).toEqual(first); expect(insertOne).not.toHaveBeenCalled(); }); - test('should keep the stored price when reusing a reservation', async () => { - promoCode.discountPercent = 30; - findRedemption.mockResolvedValue(existingReservation); - - const result = await reservePromoCode(db, input, NOW); + test.each(['userId', 'workspaceId'] as const)('should reject successful reuse by the same %s', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; - expect(result.finalAmount).toBe(80); - expect(result.discountPercent).toBe(20); - expect(insertOne).not.toHaveBeenCalled(); - }); + existingUse[otherField] = new ObjectId(); + findRedemption.mockResolvedValue(existingUse); - test('should reject a code already used by the same user in another workspace', async () => { - existingReservation.workspaceId = new ObjectId(); - findRedemption.mockResolvedValue(existingReservation); - - const result = reservePromoCode(db, input, NOW); + const result = preparePromoCode(db, input); await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); expect(insertOne).not.toHaveBeenCalled(); }); - test('should reject a code already used by another user in the same workspace', async () => { - existingReservation.userId = new ObjectId(); - findRedemption.mockResolvedValue(existingReservation); - - const result = reservePromoCode(db, input, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); - expect(insertOne).not.toHaveBeenCalled(); - }); - - test('should reject a successful use even for the same payment details', async () => { - existingReservation.status = 'used'; - findRedemption.mockResolvedValue(existingReservation); - - const result = reservePromoCode(db, input, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); - expect(insertOne).not.toHaveBeenCalled(); - }); - - test('should not reuse an expired reservation before it is released', async () => { - existingReservation.expiresAt = NOW; - findRedemption.mockResolvedValue(existingReservation); - - const result = reservePromoCode(db, input, NOW); - - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); - expect(insertOne).not.toHaveBeenCalled(); - }); - - test('should not reuse a reservation already bound to a payment', async () => { - existingReservation.transactionId = 'payment-123'; - findRedemption.mockResolvedValue(existingReservation); + test.each([0, 1])('should reject an exhausted limit of %j', async maxUses => { + promoCode.maxUses = maxUses; + countDocuments.mockResolvedValue(maxUses); - const result = reservePromoCode(db, input, NOW); + const result = preparePromoCode(db, input); - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); - expect(insertOne).not.toHaveBeenCalled(); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + expect(countDocuments).toHaveBeenCalledWith({ promoCodeId: promoCode._id }); }); - test('should not reuse a reservation for another plan', async () => { - existingReservation.tariffPlanId = new ObjectId(); - findRedemption.mockResolvedValue(existingReservation); + test.each([undefined, -1, '2'])('should ignore invalid or missing limit %j', async maxUses => { + findPromoCode.mockResolvedValue({ ...promoCode, maxUses }); - const result = reservePromoCode(db, input, NOW); + const result = await preparePromoCode(db, input); - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); - expect(insertOne).not.toHaveBeenCalled(); + expect(result.finalAmount).toBe(80); + expect(countDocuments).not.toHaveBeenCalled(); }); - test('should not reuse a reservation for another original price', async () => { - existingReservation.originalAmount = 200; - findRedemption.mockResolvedValue(existingReservation); + test('should reject a plan outside the allowed list', async () => { + promoCode.planIds = [ new ObjectId() ]; - const result = reservePromoCode(db, input, NOW); + const result = preparePromoCode(db, input); - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); expect(insertOne).not.toHaveBeenCalled(); }); - test('should count reserved and used records before reserving the last available use', async () => { - promoCode.maxUses = 2; - countDocuments.mockResolvedValue(1); + test('should record only successful usage without status or expiry fields', async () => { + const callback = payment; - const result = await reservePromoCode(db, input, NOW); + await recordPromoCodeUsage(db, callback); - expect(result.status).toBe('reserved'); - expect(countDocuments).toHaveBeenCalledWith({ - promoCodeId: promoCode._id, - status: { $in: ['reserved', 'used'] }, + expect(insertOne).toHaveBeenCalledWith({ + ...existingUse, + _id: expect.any(ObjectId), }); - expect(insertOne).toHaveBeenCalledTimes(1); - expect(promoCode.maxUses).toBe(2); - }); - - test.each([0, 1, 2])('should reject a reservation when limit %j is exhausted', async maxUses => { - promoCode.maxUses = maxUses; - countDocuments.mockResolvedValue(maxUses); - - const result = reservePromoCode(db, input, NOW); + const stored = insertOne.mock.calls[0][0]; - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); - expect(insertOne).not.toHaveBeenCalled(); + expect(stored).not.toHaveProperty('status'); + expect(stored).not.toHaveProperty('expiresAt'); }); - test.each(['10', -1, null, undefined, NaN, Infinity])( - 'should allow unlimited use when maxUses is %j', async maxUses => { - findPromoCode.mockResolvedValue({ - ...promoCode, - maxUses, - }); - - const result = await reservePromoCode(db, input, NOW); - - expect(result.status).toBe('reserved'); - expect(countDocuments).not.toHaveBeenCalled(); - expect(insertOne).toHaveBeenCalledTimes(1); - } - ); + 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' })); - test('should reuse the same reservation created by a concurrent request', async () => { - findRedemption.mockResolvedValueOnce(null).mockResolvedValueOnce(existingReservation); - insertOne.mockRejectedValue(new MongoServerError({ - message: 'Duplicate key', - code: 11000, - })); + const result = recordPromoCodeUsage(db, payment); - const result = await reservePromoCode(db, input, NOW); - - expect(result).toBe(existingReservation); - expect(findRedemption).toHaveBeenCalledTimes(2); + await expect(result).resolves.toBeUndefined(); }); - test('should reject a concurrent duplicate use by another user', async () => { - existingReservation.userId = new ObjectId(); - findRedemption.mockResolvedValueOnce(null).mockResolvedValueOnce(existingReservation); - insertOne.mockRejectedValue(new MongoServerError({ - message: 'Duplicate key', - code: 11000, - })); + 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); - const result = reservePromoCode(db, input, NOW); + try { + const result = recordPromoCodeUsage(db, payment); - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + await expect(result).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } }); - test('should propagate a database write failure', async () => { + test('should propagate a database failure so the callback can be retried', async () => { const error = new Error('Database unavailable'); insertOne.mockRejectedValue(error); - const result = reservePromoCode(db, input, NOW); + const result = recordPromoCodeUsage(db, payment); await expect(result).rejects.toBe(error); }); - test('should not reserve a disabled promo code', async () => { - promoCode.isDisabled = true; + test.each([1, 81, NaN])('should reject a callback with a changed amount %j', amount => { + payment.amount = amount; - const result = reservePromoCode(db, input, NOW); + const action = (): void => validatePromoPayment(payment); - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + expect(action).toThrow('PROMO_CODE_WRONG_AMOUNT'); expect(insertOne).not.toHaveBeenCalled(); }); });