From 7534e060aaf2b03238e967995e0d94119d108137 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 13 Oct 2026 14:17:10 +0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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(); }); }); From ea0aa881cde1cd7a02842508f959f2af1f8ccbe2 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 21 Oct 2026 11:37:43 +0300 Subject: [PATCH 07/12] lint fix --- src/billing/cloudpayments.ts | 1 - src/billing/promoCodes/redemptions.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index aedf0bb1..ee11de0f 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -904,7 +904,6 @@ status: ${body.Status}` upsert: true, }); - res.json({ code: CheckCodes.SUCCESS }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index 8454f33b..07fda61b 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -59,7 +59,7 @@ export async function checkPromoCodeUsage( const redemptions = db.collection('promoCodeRedemptions'); const existing = await redemptions.findOne({ promoCodeId, - $or: [{ userId }, { workspaceId }], + $or: [ { userId }, { workspaceId } ], }); if (existing) { @@ -160,7 +160,7 @@ export async function recordPromoCodeUsage(db: Db, payment: PromoPayment): Promi const existing = await redemptions.findOne({ promoCodeId: usage.promoCodeId, - $or: [{ userId: usage.userId }, { workspaceId: usage.workspaceId }], + $or: [ { userId: usage.userId }, { workspaceId: usage.workspaceId } ], }); if (!existing) { From e1b30032034d7d6c467ebef25df6d89fc2606758 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 21 Oct 2026 12:37:48 +0300 Subject: [PATCH 08/12] chore --- ...013000000-create-promo-code-collections.js | 12 + src/billing/cloudpayments.ts | 131 ++-- src/billing/promoCodes/application.ts | 109 +++- src/billing/promoCodes/index.ts | 21 + src/billing/promoCodes/redemptions.ts | 87 +++ src/billing/types/paymentData.ts | 14 +- src/resolvers/billingNew.ts | 32 +- src/utils/checksumService.ts | 22 +- test/services/promoCodes/application.test.ts | 158 ++++- test/services/promoCodes/callbacks.test.ts | 562 ++++++++++++++++++ test/services/promoCodes/index.test.ts | 15 + test/services/promoCodes/redemptions.test.ts | 32 +- 12 files changed, 1086 insertions(+), 109 deletions(-) create mode 100644 test/services/promoCodes/callbacks.test.ts diff --git a/migrations/20261013000000-create-promo-code-collections.js b/migrations/20261013000000-create-promo-code-collections.js index b4d7df2c..f0e15b25 100644 --- a/migrations/20261013000000-create-promo-code-collections.js +++ b/migrations/20261013000000-create-promo-code-collections.js @@ -3,6 +3,12 @@ * MongoDB creates the collections when their indexes are created. */ module.exports = { + /** + * Create unique indexes for promo codes and successful redemptions. + * + * @param {Db} db - MongoDB database receiving the promo code indexes. + * @returns {Promise} Resolves when all indexes have been created. + */ async up(db) { await db.collection('promoCodes').createIndex({ value: 1, @@ -28,6 +34,12 @@ module.exports = { }); }, + /** + * Remove the promo code indexes while preserving collection data. + * + * @param {Db} db - MongoDB database containing the promo code indexes. + * @returns {Promise} Resolves when all indexes have been removed. + */ async down(db) { await db.collection('promoCodeRedemptions').dropIndex('promoCodeWorkspace'); await db.collection('promoCodeRedemptions').dropIndex('promoCodeUser'); diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index ee11de0f..d4373f47 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -3,7 +3,7 @@ import { ObjectId, Db } from 'mongodb'; import { databases } from '../mongo'; import { checkPromoCodeUsage, recordPromoCodeUsage, validatePromoPayment, PromoPayment } from './promoCodes/redemptions'; import { findActivePromoCode } from './promoCodes'; -import { applyPromoPlan } from './promoCodes/application'; +import { applyPaidPlan } from './promoCodes/application'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import { @@ -53,6 +53,9 @@ const PENNY_MULTIPLIER = 100; * Class for describing the logic of payment routes */ export default class CloudPaymentsWebhooks { + /** + * Authenticated CloudPayments client used for subscriptions and receipts. + */ private readonly clientService = new ClientService({ publicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', privateKey: process.env.CLOUDPAYMENTS_SECRET || '', @@ -97,6 +100,7 @@ export default class CloudPaymentsWebhooks { * * @param tariffPlan - tariff plan to generate invoice id * @param workspace - workspace data to generate invoice id + * @returns human-readable invoice label for the payment widget */ private generateInvoiceId(tariffPlan: PlanDBScheme, workspace: WorkspaceModel): string { const now = new Date(); @@ -128,7 +132,9 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ + /** + * Validate the identity required for a card link or a plan purchase. + */ if (data.isCardLinkOperation) { if (!data.userId || !data.workspaceId) { this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the card linking request', req.body); @@ -245,7 +251,9 @@ export default class CloudPaymentsWebhooks { return; } - /** Data validation */ + /** + * Validate the identity required for a card link or a plan purchase. + */ if (data.isCardLinkOperation) { if (!data.userId || !data.workspaceId) { this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace or user id in request body', req.body); @@ -287,39 +295,28 @@ export default class CloudPaymentsWebhooks { } try { - await businessOperation.setStatus(BusinessOperationStatus.Confirmed); - - if (!data.isCardLinkOperation) { - await workspace.changePlan(tariffPlan._id); - } - - const subscriptionId = body.SubscriptionId; - - /** - * Cancellation of the current subscription if: - * 1) the user pays manually (the workspace has an active subscription, but the request body does not) - * 2) if payment is made for another subscription (subscriptions id are not equal) - */ - if (workspace.subscriptionId) { - if (!subscriptionId || subscriptionId !== workspace.subscriptionId) { - await this.clientApi.cancelSubscription({ - Id: workspace.subscriptionId, - }); - } + if (!businessOperation._id) { + throw new Error('Business operation has no id'); } - if (subscriptionId) { - await workspace.setSubscriptionId(subscriptionId); - } + await applyPaidPlan( + this.getPromoDatabase(), workspace._id, tariffPlan._id, + businessOperation._id, businessOperation.dtCreated, + body.SubscriptionId || null, data.isCardLinkOperation + ); + await this.cancelSupersededSubscriptions(workspace._id); + await businessOperation.setStatus(BusinessOperationStatus.Confirmed); } catch (e) { const error = e as Error; - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + this.sendError(res.status(500), PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); return; } - // let accountId = workspace.accountId; + /* + * let accountId = workspace.accountId; + */ /* * try { @@ -351,7 +348,9 @@ export default class CloudPaymentsWebhooks { * const error = e as Error; */ - // this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body); + /* + * this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body); + */ /* * return; @@ -377,7 +376,9 @@ export default class CloudPaymentsWebhooks { } try { - // todo: add plan-prolongation notification if it was a payment by subscription + /** + * @todo Add plan-prolongation notification for a subscription payment. + */ const senderWorkerTask: PaymentSuccessNotificationTask = { type: SenderWorkerTaskType.PaymentSuccess, payload: { @@ -658,7 +659,13 @@ status: ${body.Status}` * Subscription was cancelled via CloudPayments admin panel (or other no-garage way). * Remove subscription id from workspace. */ - await workspace.setSubscriptionId(null); + await this.getPromoDatabase().collection('workspaces') + .updateOne({ + _id: workspace._id, + subscriptionId: body.Id, + }, { + $unset: { subscriptionId: '' }, + }); } catch (e) { const error = e as Error; @@ -679,6 +686,7 @@ status: ${body.Status}` * Formats workspace identity for Telegram money notifications * * @param workspace - workspace model or null when it was not found + * @returns workspace name and identifier, or a fallback for missing data */ private formatWorkspaceForTelegram(workspace: Pick | null): string { if (!workspace) { @@ -697,6 +705,7 @@ status: ${body.Status}` * * @param req - express request * @param workspaceId - id of workspace + * @returns existing workspace model */ private async getWorkspace(req: express.Request, workspaceId: string): Promise { const workspace = await req.context.factories.workspacesFactory.findById(workspaceId); @@ -713,6 +722,7 @@ status: ${body.Status}` * * @param req - express request * @param userId - id of user to fetch + * @returns existing payer model */ private async getUser(req: express.Request, userId: string): Promise { const user = await req.context.factories.usersFactory.findById(userId); @@ -729,6 +739,7 @@ status: ${body.Status}` * * @param req - express request * @param transactionId - id of the transaction for fetching business operation + * @returns existing business operation for the provider transaction */ private async getBusinessOperation(req: express.Request, transactionId: string): Promise { const businessOperation = await req.context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId); @@ -745,6 +756,7 @@ status: ${body.Status}` * * @param userId - id of current user * @param workspace - workspace data + * @returns confirmed workspace administrator allowed to pay */ private async getMember(userId: string, workspace: WorkspaceModel): Promise { const user = await workspace.getMemberInfo(userId); @@ -769,6 +781,7 @@ status: ${body.Status}` * * @param req - express request * @param tariffPlanId - plan id + * @returns existing purchased plan model */ private async getPlan(req: express.Request, tariffPlanId: string): Promise { const plan = await req.context.factories.plansFactory.findById(tariffPlanId); @@ -802,9 +815,9 @@ status: ${body.Status}` } /** - * Require the accounts database for promo payments. + * Require the accounts database shared by paid-plan and subscription updates. * - * @returns accounts database for promo payments + * @returns connected accounts database */ private getPromoDatabase(): Db { if (!databases.hawk) { @@ -1021,23 +1034,15 @@ status: ${body.Status}` await recordPromoCodeUsage(db, payment); await this.updatePromoOperation(db, operationId, token); - const ownsBillingPeriod = await applyPromoPlan(db, workspace._id, plan._id, operationId, operation.dtCreated); - - if (ownsBillingPeriod) { - const subscriptionId = req.body.SubscriptionId; - - await this.updatePromoOperation(db, operationId, token); - - if (workspace.subscriptionId && workspace.subscriptionId !== subscriptionId) { - await this.clientApi.cancelSubscription({ Id: workspace.subscriptionId }); - } - - await this.updatePromoOperation(db, operationId, token); + const ownsBillingPeriod = await applyPaidPlan( + db, workspace._id, plan._id, operationId, operation.dtCreated, + req.body.SubscriptionId || null + ); - if (subscriptionId) { - await workspace.setSubscriptionId(subscriptionId); - } + await this.updatePromoOperation(db, operationId, token); + await this.cancelSupersededSubscriptions(workspace._id); + if (ownsBillingPeriod) { await this.updatePromoOperation(db, operationId, token); await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ type: 'unblock-workspace', @@ -1132,6 +1137,36 @@ status: ${body.Status}` } } + /** + * Retry cancellation without touching the subscription selected by a newer payment. + * + * @param workspaceId - workspace with durable pending cancellations + */ + private async cancelSupersededSubscriptions(workspaceId: ObjectId): Promise { + const workspaces = this.getPromoDatabase().collection<{ + _id: ObjectId; + subscriptionId?: string; + subscriptionIdsToCancel?: string[]; + }>('workspaces'); + const workspace = await workspaces.findOne({ _id: workspaceId }); + + for (const subscriptionId of workspace?.subscriptionIdsToCancel || []) { + if (subscriptionId === workspace?.subscriptionId) { + continue; + } + + const cancellation = await this.clientApi.cancelSubscription({ Id: subscriptionId }); + + if (!cancellation.isSuccess()) { + throw new Error(`Could not cancel superseded subscription ${subscriptionId}`); + } + + await workspaces.updateOne({ _id: workspaceId }, { + $pull: { subscriptionIdsToCancel: subscriptionId }, + }); + } + } + /** * Renew callback ownership and fence writes from an expired handler. * @@ -1172,6 +1207,7 @@ status: ${body.Status}` * Parses request body and returns data from it * * @param req - request with necessary data + * @returns signed payment data or identity resolved from an existing subscription */ private async getDataFromRequest(req: express.Request): Promise { const context = req.context; @@ -1241,6 +1277,7 @@ status: ${body.Status}` /** * Parses body and returns card data * @param request - request body to parse + * @returns reusable card details, or null when any required field is missing */ private getCardData(request: PayRequest): Omit | null { if (!request.CardType || !request.CardExpDate || !request.CardLastFour || !request.CardFirstSix || !request.Token) { diff --git a/src/billing/promoCodes/application.ts b/src/billing/promoCodes/application.ts index 6ac8339a..66fe46ac 100644 --- a/src/billing/promoCodes/application.ts +++ b/src/billing/promoCodes/application.ts @@ -1,64 +1,123 @@ 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. + * Apply a payment's billing period and subscription atomically, without resetting + * usage on a retry. Preserve superseded subscriptions until cancellation succeeds. * * @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 + * @param subscriptionId - new subscription, or null for a one-time payment + * @param isCardLinkOperation - replace the subscription without restarting the paid period + * @returns whether this payment still owns the workspace billing state */ -export async function applyPromoPlan( +export async function applyPaidPlan( db: Db, workspaceId: ObjectId, planId: ObjectId, operationId: ObjectId, - paymentDate: Date + paymentDate: Date, + subscriptionId: string | null = null, + isCardLinkOperation = false ): Promise { if (!(paymentDate instanceof Date) || !Number.isFinite(paymentDate.getTime())) { throw new Error('PROMO_PAYMENT_INVALID_DATE'); } + /** + * Linking a card must not supersede a paid billing period whose callback is delayed. + */ + const paymentIdField = isCardLinkOperation ? 'cardLinkPaymentId' : 'billingPaymentId'; + const paymentDateField = isCardLinkOperation ? 'cardLinkPaymentDate' : 'billingPaymentDate'; const workspaces = db.collection('workspaces'); const result = await workspaces.updateOne({ _id: workspaceId, - promoCodePaymentId: { $ne: operationId }, - $or: [ - { lastChargeDate: { $exists: false } }, - { lastChargeDate: { $lt: paymentDate } }, + [paymentIdField]: { $ne: operationId }, + $and: [ { - lastChargeDate: paymentDate, - promoCodePaymentId: { $exists: false }, + $or: [ + { lastChargeDate: { $exists: false } }, + { lastChargeDate: { $lte: paymentDate } }, + ], }, { - lastChargeDate: paymentDate, - promoCodePaymentId: { $lt: operationId }, + $or: [ + { [paymentDateField]: { $exists: false } }, + { [paymentDateField]: { $lt: paymentDate } }, + { + [paymentDateField]: paymentDate, + [paymentIdField]: { $lt: operationId }, + }, + ], }, ], - }, { - $set: { - tariffPlanId: planId, - billingPeriodEventsCount: 0, - lastChargeDate: paymentDate, - promoCodePaymentId: operationId, + }, [ + { + $set: { + [paymentIdField]: { $literal: operationId }, + [paymentDateField]: { $literal: paymentDate }, + subscriptionIdsToCancel: { + $setUnion: [ + { + $setDifference: [ + { $ifNull: ['$subscriptionIdsToCancel', [] ] }, + { $literal: subscriptionId ? [ subscriptionId ] : [] }, + ], + }, + { + $cond: [ + { + $and: [ + { $ne: [ { $ifNull: ['$subscriptionId', null] }, null] }, + { $ne: ['$subscriptionId', { $literal: subscriptionId } ] }, + ], + }, + [ '$subscriptionId' ], + [], + ], + }, + ], + }, + subscriptionId: subscriptionId ? { $literal: subscriptionId } : '$$REMOVE', + ...(!isCardLinkOperation ? { + tariffPlanId: { $literal: planId }, + billingPeriodEventsCount: 0, + lastChargeDate: { $literal: paymentDate }, + } : {}), + }, }, - }); + ]); if (result.matchedCount === 1) { return true; } - const workspace = await workspaces.findOne({ _id: workspaceId }, { - projection: { promoCodePaymentId: 1 }, - }); + const workspace = await workspaces.findOne({ _id: workspaceId }); if (!workspace) { throw new Error('Workspace not found'); } - return workspace.promoCodePaymentId?.equals(operationId) === true; + const ownsBillingPeriod = workspace[paymentIdField]?.equals(operationId) === true && + workspace[paymentDateField]?.getTime() === paymentDate.getTime() && + (isCardLinkOperation + ? (!workspace.lastChargeDate || workspace.lastChargeDate <= paymentDate) && + workspace.subscriptionId === (subscriptionId || undefined) + : workspace.lastChargeDate?.getTime() === paymentDate.getTime()); + + if (!ownsBillingPeriod && subscriptionId) { + /** + * A late paid callback must not leave its superseded subscription charging again. + */ + await workspaces.updateOne({ + _id: workspaceId, + subscriptionId: { $ne: subscriptionId }, + }, { + $addToSet: { subscriptionIdsToCancel: subscriptionId }, + }); + } + + return ownsBillingPeriod; } diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index fc994722..600c99f1 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -2,12 +2,33 @@ import type { Db } from 'mongodb'; import { ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; +/** + * Promo code configuration stored in the accounts database. + */ export interface PromoCode { + /** + * Identifier referenced by successful redemptions and payment checksums. + */ _id: ObjectId; + /** + * Uppercase code without surrounding whitespace. + */ value: string; + /** + * Percentage discount for the current payment, from 1 to 99. + */ discountPercent: number; + /** + * Whether the code is unavailable for new payments. + */ isDisabled?: boolean; + /** + * Limit on successful uses; missing or invalid values mean unlimited use. + */ maxUses?: number; + /** + * Eligible plans; missing means all plans, while an empty array allows none. + */ planIds?: ObjectId[]; } diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index 07fda61b..bc7c5a88 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -3,40 +3,127 @@ import { MongoServerError, ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; import { calculateDiscountedAmount, findActivePromoCode, getMaxUses } from './index'; +/** + * Server-calculated price snapshot used to sign a promo payment. + */ export interface PromoCodeQuote { + /** + * Identifier of the applied promo code. + */ promoCodeId: ObjectId; + /** + * Full plan price in rubles before the discount. + */ originalAmount: number; + /** + * Discounted price in whole rubles, with a one-ruble minimum. + */ finalAmount: number; + /** + * Percentage discount captured when the quote was prepared. + */ discountPercent: number; } +/** + * Successful promo use, unique per code and user or workspace. + */ export interface PromoCodeRedemption extends PromoCodeQuote { + /** + * Identifier of the successful usage record. + */ _id: ObjectId; + /** + * User who made the payment. + */ userId: ObjectId; + /** + * Workspace that received the paid plan. + */ workspaceId: ObjectId; + /** + * Plan purchased with the discount. + */ tariffPlanId: ObjectId; + /** + * Provider transaction identifier used to recognize callback retries. + */ transactionId: string; } +/** + * User input and server-resolved payment identity for preparing a quote. + */ export interface PromoCodeInput { + /** + * User-provided code, normalized before lookup. + */ promoCode: string; + /** + * Authenticated payer. + */ userId: ObjectId; + /** + * Authorized workspace receiving the plan. + */ workspaceId: ObjectId; + /** + * Selected plan checked against the code's eligibility rules. + */ tariffPlanId: ObjectId; + /** + * Server-resolved full plan price in rubles. + */ originalAmount: number; } +/** + * Signed promo identity and price combined with provider callback fields. + */ export interface PromoPayment { + /** + * Promo code identifier from the verified checksum. + */ promoCodeId: string; + /** + * Signed payment-attempt identifier, also used as the business operation ID. + */ promoPaymentId: string; + /** + * Payer identifier from the verified checksum. + */ userId: string; + /** + * Workspace identifier from the verified checksum. + */ workspaceId: string; + /** + * Purchased plan identifier from the verified checksum. + */ tariffPlanId: string; + /** + * Signed full plan price in rubles. + */ originalAmount: number; + /** + * Signed percentage discount for the payment. + */ discountPercent: number; + /** + * Signed discounted amount in rubles. + */ paymentAmount: number; + /** + * Provider transaction identifier from the callback. + */ transactionId: string; + /** + * Actual callback amount in rubles, compared with the signed quote. + */ amount: number; + /** + * Provider callback currency; promo payments require RUB. + */ currency: string; } diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index b4f22309..7abe603e 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -35,6 +35,9 @@ interface CloudPaymentsSettings { recurrent: RecurrentPaymentSettings; } +/** + * Payment identity parsed from a checksum or an existing subscription. + */ export interface PaymentData { /** * Data for Cloudpayments needs @@ -65,11 +68,20 @@ export interface PaymentData { */ paymentAmount?: number; /** - * Signed promo payment attempt and price snapshot + * Signed promo payment attempt, also used as the business operation ID. */ promoPaymentId?: string; + /** + * Signed identifier of the promo code applied to this payment. + */ promoCodeId?: string; + /** + * Signed full plan price in rubles before the discount. + */ originalAmount?: number; + /** + * Signed percentage discount captured when the quote was prepared. + */ discountPercent?: number; /** * Signed start date for future full-price payments diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index 6e2b1c2b..cef7df25 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -26,10 +26,25 @@ const AMOUNT_FOR_CARD_VALIDATION = 1; * Input data for composePayment query */ interface ComposePaymentArgs { + /** + * Workspace, selected plan and optional discount requested by the payer. + */ input: { + /** + * Workspace whose administrator is preparing the payment. + */ workspaceId: string; + /** + * Plan used for pricing and promo eligibility checks. + */ tariffPlanId: string; + /** + * Whether to retain the provider's reusable card token after payment. + */ shouldSaveCard?: boolean; + /** + * User-provided promo code; missing or blank means full-price payment. + */ promoCode?: string | null; }; } @@ -67,6 +82,7 @@ export default { * @param ids - ids of workspaces for which transactions have been requested * @param user - current authorized user * @param factories - factories for working with models + * @returns business operations belonging to the requested workspaces */ async businessOperations( _obj: undefined, @@ -77,7 +93,13 @@ export default { }, /** - * GraphQL version of composePayment: prepares data before charge + * Prepare the server-priced payment and sign its identity and optional discount. + * + * @param _obj - parent resolver value + * @param input - workspace, plan, card-saving preference and optional promo code + * @param user - authenticated payer + * @param factories - model factories for authorization and plan lookup + * @returns widget settings, current amount in rubles and a signed payment checksum */ async composePayment( _obj: undefined, @@ -125,9 +147,9 @@ export default { * 3. Workspace is not blocked */ if ( - workspace.tariffPlanId.toString() === tariffPlanId && // 1 - !workspace.isTariffPlanExpired() && // 2 - !workspace.isBlocked // 3 + workspace.tariffPlanId.toString() === tariffPlanId && + !workspace.isTariffPlanExpired() && + !workspace.isBlocked ) { isCardLinkOperation = true; } @@ -236,6 +258,7 @@ debug: ${Boolean(workspace.isDebug)}` /** * Returns type of the payload * @param payload - result from resolver above + * @returns GraphQL payload type matching the operation data */ __resolveType(payload: BusinessOperationPayloadType): string { if ('cardPan' in payload) { @@ -304,6 +327,7 @@ debug: ${Boolean(workspace.isDebug)}` * @param args - mutation args * @param user - current authorized user * @param factories - factories for working with models + * @returns business operation produced by the tokenized payment */ async payWithCard(_obj: undefined, args: PayWithCardArgs, { factories, user }: ResolverContextWithUser): Promise { const paymentData = checksumService.parseAndVerifyChecksum(args.input.checksum); diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 07f77527..1bafb2a2 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,8 +1,14 @@ import jwt, { Secret } from 'jsonwebtoken'; import { UserInputError } from 'apollo-server-express'; +/** + * Signed identity for a plan purchase or a card-linking payment. + */ export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; +/** + * Plan purchase data with an optional signed promo price snapshot. + */ interface PlanPurchaseChecksumData { /** * Workspace Identifier @@ -29,14 +35,26 @@ interface PlanPurchaseChecksumData { */ paymentAmount?: number; /** - * Signed identity and price of the promo payment attempt + * Signed payment attempt, also used as the business operation ID. */ promoPaymentId?: string; + /** + * Identifier of the applied promo code. + */ promoCodeId?: string; + /** + * Full plan price in rubles before the discount. + */ originalAmount?: number; + /** + * Percentage discount captured when the quote was prepared. + */ discountPercent?: number; } +/** + * Signed workspace and payer identity for validating a recurring-payment card. + */ interface CardLinkChecksumData { /** * Workspace Identifier @@ -64,6 +82,7 @@ class ChecksumService { * Generates checksum for processing billing requests * * @param data - data for processing billing request + * @returns signed payment token valid for thirty minutes */ public async generateChecksum(data: ChecksumData): Promise { return jwt.sign(data, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, { expiresIn: '30m' }); @@ -74,6 +93,7 @@ class ChecksumService { * * @param checksum - checksum to parse * @param allowExpiredPromo - allow a late callback for a checked promo payment + * @returns verified payment identity without unsigned callback fields */ public parseAndVerifyChecksum(checksum: string, allowExpiredPromo = false): ChecksumData { const secret = process.env.JWT_SECRET_BILLING_CHECKSUM as Secret; diff --git a/test/services/promoCodes/application.test.ts b/test/services/promoCodes/application.test.ts index 34afcd6e..bee855a4 100644 --- a/test/services/promoCodes/application.test.ts +++ b/test/services/promoCodes/application.test.ts @@ -1,9 +1,12 @@ import { Db, MongoClient, ObjectId } from 'mongodb'; -import { applyPromoPlan } from '../../../src/billing/promoCodes/application'; +import { applyPaidPlan } from '../../../src/billing/promoCodes/application'; const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); -describe('Promo plan application', () => { +/** + * Ordinary and promo callbacks must use the same atomic billing transition. + */ +describe('Paid plan application', () => { let client: MongoClient; let db: Db; let workspaceId: ObjectId; @@ -47,27 +50,26 @@ describe('Promo plan application', () => { }); 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 result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_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, + lastChargeDate: PAYMENT_DATE, + billingPaymentDate: PAYMENT_DATE, + billingPaymentId: 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 applyPaidPlan(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 result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); expect(result).toBe(true); @@ -76,56 +78,160 @@ describe('Promo plan application', () => { }); test('should not overwrite a newer paid plan with an older callback', async () => { - await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); - + await applyPaidPlan(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); + await applyPaidPlan(db, workspaceId, newerPlanId, newerOperationId, newerDate, 'sub-new'); + + const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(false); + expect(workspace).toMatchObject({ + tariffPlanId: newerPlanId, + lastChargeDate: newerDate, + billingPaymentId: newerOperationId, + subscriptionId: 'sub-new', + subscriptionIdsToCancel: [ 'sub-old' ], + }); + }); + + test('should reject old ownership when a later billing update leaves the payment marker unchanged', async () => { + await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); + const newerDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); + + await db.collection('workspaces').updateOne({ _id: workspaceId }, { + $set: { + lastChargeDate: newerDate, + subscriptionId: 'sub-new', + billingPeriodEventsCount: 7, + }, + }); - const result = await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); expect(result).toBe(false); - expect(workspace?.tariffPlanId).toEqual(newerPlanId); + expect(workspace?.subscriptionId).toBe('sub-new'); + expect(workspace?.billingPeriodEventsCount).toBe(7); 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))); + const results = await Promise.all(requests.map(id => applyPaidPlan(db, workspaceId, planId, id, PAYMENT_DATE))); await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 }, }); - await applyPromoPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + await applyPaidPlan(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); + expect(workspace?.billingPaymentId).toEqual(operationId); }); - test('should reject a missing workspace', async () => { - const missingWorkspaceId = new ObjectId(); + test('should atomically replace the subscription and retain pending cancellations on retry', async () => { + await db.collection('workspaces').updateOne({ _id: workspaceId }, { + $set: { subscriptionId: 'sub-old' }, + }); + await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-new'); + await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-new'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace).toMatchObject({ + tariffPlanId: planId, + subscriptionId: 'sub-new', + subscriptionIdsToCancel: [ 'sub-old' ], + }); + }); + + test('should remove a subscription for a one-time payment without losing its cancellation', async () => { + await db.collection('workspaces').updateOne({ _id: workspaceId }, { + $set: { subscriptionId: 'sub-old' }, + }); + await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace).not.toHaveProperty('subscriptionId'); + expect(workspace?.subscriptionIdsToCancel).toEqual([ 'sub-old' ]); + }); - const result = applyPromoPlan(db, missingWorkspaceId, planId, operationId, PAYMENT_DATE); + test('should keep the paid period and usage when only linking a card', async () => { + const before = await db.collection('workspaces').findOne({ _id: workspaceId }); + + await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-card', true); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace?.tariffPlanId).toEqual(before?.tariffPlanId); + expect(workspace?.lastChargeDate).toEqual(before?.lastChargeDate); + expect(workspace?.billingPeriodEventsCount).toBe(50); + expect(workspace?.subscriptionId).toBe('sub-card'); + }); + + test('a newer card link must not prevent applying an earlier successful plan purchase', async () => { + const cardLinkId = new ObjectId(); + const cardLinkDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); + + await applyPaidPlan(db, workspaceId, planId, cardLinkId, cardLinkDate, 'sub-card', true); + const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-paid'); + const retry = await applyPaidPlan(db, workspaceId, planId, cardLinkId, cardLinkDate, 'sub-card', true); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(true); + expect(retry).toBe(false); + expect(workspace).toMatchObject({ + tariffPlanId: planId, + lastChargeDate: PAYMENT_DATE, + billingPaymentId: operationId, + subscriptionId: 'sub-paid', + subscriptionIdsToCancel: [ 'sub-card' ], + }); + }); + + test('an old card link must not replace a newer paid subscription', async () => { + const newerDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); + const paymentId = new ObjectId(); + + await applyPaidPlan(db, workspaceId, planId, paymentId, newerDate, 'sub-paid'); + const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-card', true); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(false); + expect(workspace?.subscriptionId).toBe('sub-paid'); + expect(workspace?.billingPaymentId).toEqual(paymentId); + expect(workspace?.subscriptionIdsToCancel).toEqual([ 'sub-card' ]); + }); + + test('payments checked at the same time must use their operation id as a stable tie breaker', async () => { + const olderId = new ObjectId('000000000000000000000001'); + const newerId = new ObjectId('000000000000000000000002'); + + await applyPaidPlan(db, workspaceId, planId, newerId, PAYMENT_DATE, 'sub-new'); + const result = await applyPaidPlan(db, workspaceId, new ObjectId(), olderId, PAYMENT_DATE, 'sub-old'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(result).toBe(false); + expect(workspace?.tariffPlanId).toEqual(planId); + expect(workspace?.subscriptionId).toBe('sub-new'); + }); + + test('should reject a missing workspace', async () => { + const result = applyPaidPlan(db, new ObjectId(), 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); + const result = applyPaidPlan(db, workspaceId, planId, operationId, new Date('invalid')); 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(); + expect(workspace).not.toHaveProperty('billingPaymentId'); }); }); diff --git a/test/services/promoCodes/callbacks.test.ts b/test/services/promoCodes/callbacks.test.ts new file mode 100644 index 00000000..b505355d --- /dev/null +++ b/test/services/promoCodes/callbacks.test.ts @@ -0,0 +1,562 @@ +import type { Request, Response } from 'express'; +import { Db, MongoClient, ObjectId } from 'mongodb'; +import type { Document, WithId } from 'mongodb'; +import CloudPaymentsWebhooks from '../../../src/billing/cloudpayments'; +import type { PaymentData } from '../../../src/billing/types/paymentData'; +import { SubscriptionStatus } from '../../../src/billing/types/enums'; +import { BusinessOperationStatus, BusinessOperationType } from '@hawk.so/types'; +import { databases } from '../../../src/mongo'; +import DataLoaders from '../../../src/dataLoaders'; +import WorkspacesFactory from '../../../src/models/workspacesFactory'; +import UsersFactory from '../../../src/models/usersFactory'; +import PlansFactory from '../../../src/models/plansFactory'; +import BusinessOperationsFactory from '../../../src/models/businessOperationsFactory'; +import checksumService from '../../../src/utils/checksumService'; +import { publish } from '../../../src/rabbitmq'; +import sendNotification from '../../../src/utils/personalNotifications'; + +/** + * Metrics starts a process-wide cleanup interval; it is unrelated to callback persistence. + */ +jest.mock('../../../src/metrics', () => ({ + setupMongoMetrics: jest.fn(), + withMongoMetrics: (options: unknown): unknown => options, +})); +jest.mock('../../../src/rabbitmq', () => ({ publish: jest.fn() })); +jest.mock('../../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('../../../src/utils/telegram', () => ({ + ...jest.requireActual('../../../src/utils/telegram'), + sendMessage: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@hawk.so/nodejs', () => ({ + __esModule: true, + default: { send: jest.fn() }, +})); +jest.mock('cloudpayments', () => ({ + ...jest.requireActual('cloudpayments'), + ClientService: jest.fn().mockImplementation(() => ({ + getClientApi: (): { cancelSubscription: jest.Mock } => ({ + cancelSubscription: jest.fn().mockResolvedValue({ isSuccess: (): boolean => true }), + }), + getReceiptApi: (): { createReceipt: jest.Mock } => ({ createReceipt: jest.fn().mockResolvedValue(undefined) }), + })), +})); + +const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); +const NEWER_PAYMENT_DATE = new Date('2026-10-13T13:00:00.000Z'); + +/** + * Test view exposing callback handlers and mocked external APIs, including private members. + */ +type TestCallbacks = { + finishPromoPayment(req: Request, res: Response, data: PaymentData, succeeded: boolean): Promise; + pay(req: Request, res: Response): Promise; + recurrent(req: Request, res: Response): Promise; + clientApi: { cancelSubscription: jest.Mock }; + receiptApi: { createReceipt: jest.Mock }; +}; + +/** + * Create a barrier for pausing a callback during an external cancellation. + * + * @returns A promise and the function that releases waiting callbacks. + */ +function semaphore(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise(resolve => { + release = resolve; + }); + + return { + promise, + release, + }; +} + +/** + * Create a chainable response mock for inspecting callback acknowledgements. + * + * @returns An Express response with mocked JSON and status methods. + */ +function response(): Response & { json: jest.Mock; status: jest.Mock } { + return { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + } as unknown as Response & { json: jest.Mock; status: jest.Mock }; +} + +describe('Promo payment callbacks with MongoDB', () => { + let client: MongoClient; + let db: Db; + let callbacks: TestCallbacks; + let workspaceId: ObjectId; + let userId: ObjectId; + let factories: { + workspacesFactory: WorkspacesFactory; + usersFactory: UsersFactory; + plansFactory: PlansFactory; + businessOperationsFactory: BusinessOperationsFactory; + }; + const previousDatabase = databases.hawk; + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + + beforeAll(async () => { + const globals = global as unknown as { __MONGO_URI__?: string }; + const uri = process.env.MONGO_URL || globals.__MONGO_URI__; + + if (!uri) { + throw new Error('Jest MongoDB test URI is not configured'); + } + + client = new MongoClient(uri); + await client.connect(); + db = client.db('promo-callback-tests'); + databases.hawk = db; + process.env.JWT_SECRET_BILLING_CHECKSUM = 'promo-callback-tests-only'; + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mocked(publish).mockResolvedValue(undefined); + jest.mocked(sendNotification).mockResolvedValue(undefined); + await db.dropDatabase(); + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + userId: 1, + }, { unique: true }); + await db.collection('promoCodeRedemptions').createIndex({ + promoCodeId: 1, + workspaceId: 1, + }, { unique: true }); + workspaceId = new ObjectId(); + userId = new ObjectId(); + await db.collection('workspaces').insertOne({ + _id: workspaceId, + name: 'Callback workspace', + tariffPlanId: new ObjectId(), + billingPeriodEventsCount: 50, + lastChargeDate: new Date(PAYMENT_DATE.getTime() - 86400000), + subscriptionId: 'sub-old', + }); + await db.collection('users').insertOne({ + _id: userId, + email: 'callback@example.test', + }); + const loaders = new DataLoaders(db); + + factories = { + workspacesFactory: new WorkspacesFactory(db, loaders), + usersFactory: new UsersFactory(db, loaders), + plansFactory: new PlansFactory(db, loaders), + businessOperationsFactory: new BusinessOperationsFactory(db, loaders), + }; + callbacks = new CloudPaymentsWebhooks() as unknown as TestCallbacks; + }); + + afterAll(async () => { + databases.hawk = previousDatabase; + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + if (db) { + await db.dropDatabase(); + } + if (client) { + await client.close(); + } + }); + + /** + * Persist a pending purchase and prepare its signed payment callback. + * + * @param label - Payment label used for plan, promo code, transaction and subscription fixtures. + * @param date - Creation date of the pending business operation. + * @param promo - Whether to include promo code data and a discounted amount. + * @returns The operation and plan identifiers, decoded payment data and callback request. + */ + async function payment(label: string, date: Date, promo = true): Promise<{ + operationId: ObjectId; + planId: ObjectId; + data: PaymentData; + req: Request; + }> { + const operationId = new ObjectId(); + const planId = new ObjectId(); + const promoCodeId = new ObjectId(); + const amount = promo ? 80 : 100; + const transactionId = label === 'A' ? 1001 : 1002; + const signedData = { + workspaceId: workspaceId.toHexString(), + userId: userId.toHexString(), + tariffPlanId: planId.toHexString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + ...(promo ? { + promoPaymentId: operationId.toHexString(), + promoCodeId: promoCodeId.toHexString(), + originalAmount: 100, + paymentAmount: amount, + discountPercent: 20, + } : {}), + }; + + await db.collection('plans').insertOne({ + _id: planId, + name: `Plan ${label}`, + monthlyCharge: 100, + }); + if (promo) { + await db.collection('promoCodes').insertOne({ + _id: promoCodeId, + value: `PROMO-${label}`, + discountPercent: 20, + }); + } + await db.collection('businessOperations').insertOne({ + _id: operationId, + transactionId: transactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + dtCreated: date, + payload: { + workspaceId, + userId, + tariffPlanId: planId, + amount: amount * 100, + currency: 'RUB', + }, + }); + const checksum = await checksumService.generateChecksum(signedData); + const data: PaymentData = { + ...signedData, + isCardLinkOperation: false, + }; + const req = { + path: '/pay', + body: { + TransactionId: transactionId, + Amount: amount.toString(), + Currency: 'RUB', + SubscriptionId: `sub-${label}`, + IssuerBankCountry: 'RU', + Data: JSON.stringify({ checksum }), + }, + context: { factories }, + } as unknown as Request; + + return { + operationId, + planId, + data, + req, + }; + } + + /** + * Resume a successful promo payment through the real completion handler. + * + * @param attempt - Persisted purchase and its callback data. + * @returns The response mock containing the callback acknowledgement. + */ + async function finish(attempt: Awaited>): Promise> { + const res = response(); + + /** + * Only the private visibility is bypassed: the callback, plan application and models are real. + */ + await callbacks.finishPromoPayment(attempt.req, res, attempt.data, true); + + return res; + } + + /** + * Process a payment callback and assert a successful acknowledgement. + * + * @param attempt - Persisted purchase with the callback request to process. + * @returns The response mock after successful payment processing. + */ + async function pay(attempt: Awaited>): Promise> { + const res = response(); + + await callbacks.pay(attempt.req, res); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: 0 }); + + return res; + } + + /** + * Read the current workspace state after callback processing. + * + * @returns The persisted workspace document, or null if it is absent. + */ + async function workspace(): Promise | null> { + return db.collection('workspaces').findOne({ _id: workspaceId }); + } + + /** + * Assert promo payment completion, released processing ownership and a single redemption. + * + * @param attempt - Promo purchase whose persisted completion state should be checked. + * @returns Resolves when all completion assertions pass. + */ + async function expectFinished(attempt: Awaited>): Promise { + expect(await db.collection('businessOperations').findOne({ _id: attempt.operationId })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + promoReceiptSent: true, + promoNotificationSent: true, + }); + expect(await db.collection('businessOperations').findOne({ _id: attempt.operationId })).not.toHaveProperty('promoProcessingToken'); + expect(await db.collection('promoCodeRedemptions').countDocuments({ + promoCodeId: new ObjectId(attempt.data.promoCodeId), + })).toBe(1); + } + + test('retry of pending promo A must not cancel or replace subscription B from a later ordinary payment', async () => { + const a = await payment('A', PAYMENT_DATE); + const b = await payment('B', NEWER_PAYMENT_DATE, false); + + jest.mocked(publish).mockRejectedValueOnce(new Error('limiter publish failed')); + const failed = await finish(a); + + expect(failed.status).toHaveBeenCalledWith(500); + expect(failed.json).toHaveBeenCalledWith({ code: 1 }); + expect(await workspace()).toMatchObject({ + tariffPlanId: a.planId, + lastChargeDate: PAYMENT_DATE, + billingPaymentId: a.operationId, + billingPaymentDate: PAYMENT_DATE, + subscriptionId: 'sub-A', + }); + expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ + status: BusinessOperationStatus.Pending, + }); + + await pay(b); + const afterB = await workspace(); + + expect(afterB).toMatchObject({ + tariffPlanId: b.planId, + subscriptionId: 'sub-B', + billingPaymentId: b.operationId, + billingPaymentDate: NEWER_PAYMENT_DATE, + lastChargeDate: NEWER_PAYMENT_DATE, + subscriptionIdsToCancel: [], + }); + await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); + const cancellationsBeforeRetry = callbacks.clientApi.cancelSubscription.mock.calls.length; + + for (let retry = 0; retry < 2; retry++) { + const res = await finish(a); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: 0 }); + } + await expectFinished(a); + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(2); + expect(sendNotification).toHaveBeenCalledTimes(2); + expect(callbacks.clientApi.cancelSubscription.mock.calls.slice(cancellationsBeforeRetry)) + .not.toContainEqual([ { Id: 'sub-B' } ]); + expect(await workspace()).toMatchObject({ + tariffPlanId: b.planId, + lastChargeDate: NEWER_PAYMENT_DATE, + billingPaymentId: b.operationId, + billingPaymentDate: NEWER_PAYMENT_DATE, + subscriptionId: 'sub-B', + subscriptionIdsToCancel: [], + billingPeriodEventsCount: 7, + }); + }); + + test.each([ + ['promo', 'reject'], + ['promo', 'false'], + ['ordinary', 'reject'], + ['ordinary', 'false'], + ] as const)( + '%s cancellation %s must retain the pending subscription and retry without resetting usage', + async (kind, failure) => { + const a = await payment('A', PAYMENT_DATE, kind === 'promo'); + + if (failure === 'reject') { + callbacks.clientApi.cancelSubscription.mockRejectedValueOnce(new Error('subscription cancellation failed')); + } else { + callbacks.clientApi.cancelSubscription.mockResolvedValueOnce({ isSuccess: (): boolean => false }); + } + const failed = response(); + + await callbacks.pay(a.req, failed); + expect(failed.status).toHaveBeenCalledWith(500); + expect(await workspace()).toMatchObject({ + tariffPlanId: a.planId, + lastChargeDate: PAYMENT_DATE, + billingPaymentId: a.operationId, + billingPaymentDate: PAYMENT_DATE, + subscriptionId: 'sub-A', + subscriptionIdsToCancel: [ 'sub-old' ], + }); + expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ + status: BusinessOperationStatus.Pending, + }); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(sendNotification).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); + + await pay(a); + expect(await workspace()).toMatchObject({ + tariffPlanId: a.planId, + lastChargeDate: PAYMENT_DATE, + billingPaymentId: a.operationId, + billingPaymentDate: PAYMENT_DATE, + subscriptionId: 'sub-A', + subscriptionIdsToCancel: [], + billingPeriodEventsCount: 7, + }); + expect(callbacks.clientApi.cancelSubscription.mock.calls).toEqual([ + [ { Id: 'sub-old' } ], + [ { Id: 'sub-old' } ], + ]); + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(1); + expect(sendNotification).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); + expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + }); + if (kind === 'promo') { + await expectFinished(a); + } + } + ); + + test.each([SubscriptionStatus.CANCELLED, SubscriptionStatus.REJECTED])( + 'stale /recurrent %s must not clear a newer active subscription', + async status => { + const snapshot = await factories.workspacesFactory.findBySubscriptionId('sub-old'); + + expect(snapshot).not.toBeNull(); + const b = await payment('B', NEWER_PAYMENT_DATE); + + await pay(b); + const afterB = await workspace(); + const lookup = jest.spyOn(factories.workspacesFactory, 'findBySubscriptionId').mockResolvedValue(snapshot); + const req = { + path: '/recurrent', + body: { + Id: 'sub-old', + Status: status, + }, + context: { factories }, + } as unknown as Request; + const res = response(); + + try { + await callbacks.recurrent(req, res); + expect(lookup).toHaveBeenCalledWith('sub-old'); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ code: 0 }); + expect(await workspace()).toEqual(afterB); + expect(await workspace()).toMatchObject({ subscriptionId: 'sub-B' }); + } finally { + lookup.mockRestore(); + } + } + ); + + test.each([ + ['promo', 'promo'], + ['promo', 'ordinary'], + ['ordinary', 'promo'], + ] as const)( + '%s A / %s B: newer B must retain its plan and subscription when A resumes after external cancellation', + async (kindA, kindB) => { + const a = await payment('A', PAYMENT_DATE, kindA === 'promo'); + const b = await payment('B', NEWER_PAYMENT_DATE, kindB === 'promo'); + const cancellationEntered = semaphore(); + const resumeCancellation = semaphore(); + let paused = false; + + callbacks.clientApi.cancelSubscription.mockImplementation(async ({ Id }: { Id: string }) => { + if (Id === 'sub-old' && !paused) { + paused = true; + cancellationEntered.release(); + await resumeCancellation.promise; + } + + return { isSuccess: (): boolean => true }; + }); + const aResponse = response(); + const runningA = callbacks.pay(a.req, aResponse); + + try { + await Promise.race([ + cancellationEntered.promise, + runningA.then(() => { + throw new Error('Payment A finished without reaching the cancellation barrier'); + }), + ]); + expect(await workspace()).toMatchObject({ + tariffPlanId: a.planId, + lastChargeDate: PAYMENT_DATE, + subscriptionId: 'sub-A', + subscriptionIdsToCancel: [ 'sub-old' ], + }); + await pay(b); + expect(await workspace()).toMatchObject({ + tariffPlanId: b.planId, + lastChargeDate: NEWER_PAYMENT_DATE, + billingPaymentId: b.operationId, + billingPaymentDate: NEWER_PAYMENT_DATE, + subscriptionId: 'sub-B', + subscriptionIdsToCancel: [], + }); + } finally { + resumeCancellation.release(); + await runningA; + } + expect(aResponse.status).not.toHaveBeenCalled(); + expect(aResponse.json).toHaveBeenCalledWith({ code: 0 }); + const afterRace = await workspace(); + + await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); + const cancellations = callbacks.clientApi.cancelSubscription.mock.calls.length; + const receipts = callbacks.receiptApi.createReceipt.mock.calls.length; + const notifications = jest.mocked(sendNotification).mock.calls.length; + const publications = jest.mocked(publish).mock.calls.length; + + if (kindA === 'promo') { + await pay(a); + await expectFinished(a); + } + if (kindB === 'promo') { + await pay(b); + await expectFinished(b); + } + expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledTimes(cancellations); + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(receipts); + expect(sendNotification).toHaveBeenCalledTimes(notifications); + expect(publish).toHaveBeenCalledTimes(publications); + if (kindA === 'ordinary') { + await pay(a); + } + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalledWith({ Id: 'sub-B' }); + expect(await workspace()).toEqual({ + ...afterRace, + billingPeriodEventsCount: 7, + }); + expect(afterRace).toMatchObject({ + tariffPlanId: b.planId, + lastChargeDate: NEWER_PAYMENT_DATE, + billingPaymentId: b.operationId, + billingPaymentDate: NEWER_PAYMENT_DATE, + subscriptionId: 'sub-B', + subscriptionIdsToCancel: [], + }); + } + ); +}); diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts index 7c6ab5de..625c7718 100644 --- a/test/services/promoCodes/index.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -18,6 +18,11 @@ describe('Promo code normalization', () => { }); test.each(['', ' '])('should reject an empty code: %j', code => { + /** + * Normalize the empty code to exercise validation. + * + * @returns The normalized code if validation succeeds. + */ const action = (): string => normalizePromoCode(code); expect(action).toThrow('PROMO_CODE_INVALID'); @@ -58,6 +63,11 @@ describe('Discounted payment amount', () => { test.each([0, -1, NaN, Infinity])('should reject invalid amount %j', amount => { const discountPercent = 20; + /** + * Calculate a discount for an invalid amount to exercise validation. + * + * @returns The discounted amount if validation succeeds. + */ const action = (): number => calculateDiscountedAmount(amount, discountPercent); expect(action).toThrow('PROMO_CODE_INVALID_AMOUNT'); @@ -66,6 +76,11 @@ describe('Discounted payment amount', () => { test.each([0, -1, 100, NaN, Infinity])('should reject invalid discount %j', percent => { const originalAmount = 100; + /** + * Calculate an amount with an invalid discount to exercise validation. + * + * @returns The discounted amount if validation succeeds. + */ const action = (): number => calculateDiscountedAmount(originalAmount, percent); expect(action).toThrow('PROMO_CODE_INVALID'); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index d14ecf09..531a6fa5 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -18,7 +18,11 @@ describe('Promo code usage', () => { 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(), @@ -55,7 +59,11 @@ describe('Promo code usage', () => { return { findOne: findPromoCode }; } - return { findOne: findRedemption, countDocuments, insertOne }; + return { + findOne: findRedemption, + countDocuments, + insertOne, + }; }); findPromoCode.mockResolvedValue(promoCode); findRedemption.mockResolvedValue(null); @@ -109,7 +117,10 @@ describe('Promo code usage', () => { }); test.each([undefined, -1, '2'])('should ignore invalid or missing limit %j', async maxUses => { - findPromoCode.mockResolvedValue({ ...promoCode, maxUses }); + findPromoCode.mockResolvedValue({ + ...promoCode, + maxUses, + }); const result = await preparePromoCode(db, input); @@ -143,7 +154,10 @@ describe('Promo code usage', () => { 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' })); + insertOne.mockRejectedValue(new MongoServerError({ + code: 11000, + message: 'Duplicate key', + })); const result = recordPromoCodeUsage(db, payment); @@ -153,7 +167,10 @@ describe('Promo code usage', () => { 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' })); + insertOne.mockRejectedValue(new MongoServerError({ + code: 11000, + message: 'Duplicate key', + })); const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); try { @@ -179,6 +196,11 @@ describe('Promo code usage', () => { test.each([1, 81, NaN])('should reject a callback with a changed amount %j', amount => { payment.amount = amount; + /** + * Validate the changed callback amount to exercise payment validation. + * + * @returns Nothing if validation succeeds. + */ const action = (): void => validatePromoPayment(payment); expect(action).toThrow('PROMO_CODE_WRONG_AMOUNT'); From 0fc4e662a0033678f0c7cb9bed5639b100bb0dab Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:29:10 +0000 Subject: [PATCH 09/12] Bump version up to 1.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 85f560d3..088d9a29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.6.0", + "version": "1.6.1", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 4386d72c80cecd94f4d3b7c0f2017331cd93a232 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Tue, 27 Oct 2026 22:36:24 +0300 Subject: [PATCH 10/12] fix --- docs/PromoCodes.md | 103 +++ src/billing/cloudpayments.ts | 518 ++--------- src/billing/promoCodes/application.ts | 123 --- src/billing/promoCodes/index.ts | 76 +- src/billing/promoCodes/redemptions.ts | 242 ++--- src/billing/types/paymentData.ts | 40 +- src/billing/types/response.ts | 4 + src/index.ts | 11 + src/models/promoCodeRedemptionsFactory.ts | 89 ++ src/models/promoCodesFactory.ts | 86 ++ src/resolvers/billingNew.ts | 23 +- src/types/graphql.ts | 12 + src/utils/checksumService.ts | 38 +- test/integrations/github-routes.test.ts | 42 + test/resolvers/billingNew.test.ts | 2 + test/resolvers/project.test.ts | 2 + test/services/promoCodes/application.test.ts | 237 ----- test/services/promoCodes/billingNew.test.ts | 223 +++++ test/services/promoCodes/callbacks.test.ts | 907 ++++++++++++------- test/services/promoCodes/checksum.test.ts | 97 ++ test/services/promoCodes/index.test.ts | 231 ++--- test/services/promoCodes/redemptions.test.ts | 390 ++++++-- test/sso/saml/controller.test.ts | 2 + 23 files changed, 1843 insertions(+), 1655 deletions(-) create mode 100644 docs/PromoCodes.md delete mode 100644 src/billing/promoCodes/application.ts create mode 100644 src/models/promoCodeRedemptionsFactory.ts create mode 100644 src/models/promoCodesFactory.ts delete mode 100644 test/services/promoCodes/application.test.ts create mode 100644 test/services/promoCodes/billingNew.test.ts create mode 100644 test/services/promoCodes/checksum.test.ts diff --git a/docs/PromoCodes.md b/docs/PromoCodes.md new file mode 100644 index 00000000..224ca12e --- /dev/null +++ b/docs/PromoCodes.md @@ -0,0 +1,103 @@ +# Promo code domain and factories + +`context.factories` exposes: + +- `promoCodesFactory.calculatePromoQuote(input: PromoCodeInput): Promise` +- `promoCodesFactory.ensurePaymentAvailable(payment: PromoPayment): Promise` +- `promoCodeRedemptionsFactory.redeem(payment: PromoPayment): Promise` + +Construct with `new PromoCodeRedemptionsFactory(db)` and +`new PromoCodesFactory(db, promoCodeRedemptionsFactory)`. Both use the accounts DB. +Types and pure `validatePromoPayment(payment)` / `ensurePromoCodeAvailable(...)` +are exported from `src/billing/promoCodes/redemptions.ts`. +`applyDiscount(discount, originalAmount)`, `ensureActivePromoCode(...)`, +`normalizePromoCode(...)`, `getMaxUses(...)`, `PromoCode`, and `PromoCodeDiscount` +are exported from `src/billing/promoCodes/index.ts`. + +```ts +interface PromoCodeInput { + promoCode: string; + userId: ObjectId; + workspaceId: ObjectId; + tariffPlanId: ObjectId; + originalAmount: number; +} + +interface PromoCodeQuote { + promoCodeId: ObjectId; + originalAmount: number; + finalAmount: number; +} + +interface PromoPayment { + promoCodeId: string; + originalAmount: number; + finalAmount: number; + userId: string; + workspaceId: string; + tariffPlanId: string; + transactionId: string; + paidAmount: number; + currency: string; +} +``` + +Only call payment methods with authenticated provider callbacks and verified signed +identity / quote fields. Pure validation does not verify the checksum or authorize +the payer. It validates IDs, a nonempty transaction ID, RUB currency, valid price +bounds, and exact equality of `finalAmount` and `paidAmount`. It never recalculates +a discount. There is no promo attempt ID or coupling to a business-operation ID. + +`ensurePaymentAvailable` reads the promo code once and checks its active flag, +plan restrictions, and successful usage. Call `redeem` only after success, not at +quote/check/failure time. It does not recheck current availability: a code may have +changed or another payment may have succeeded since the provider's check. +Duplicate usage is tolerated so the caller can still fulfill an actual successful +payment. Fulfillment and business-operation idempotency remain the caller's job. + +The shared `/check` creates operations through `businessOperationsFactory.create`, +using a new operation ID for each provider transaction. A declined attempt can be +retried in the same widget without regenerating the checksum. `/pay` validates the +checked operation, records usage and reuses ordinary fulfillment, notifications +and receipts (with the actual promo amount). `/fail` does not consume the code. +A redemption storage failure returns HTTP 500 with `PayCodes.RETRY`; ordinary +payments retain the master's acknowledgment and subscription-cancellation behavior. +No lease or exactly-once guarantee for fulfillment side effects is introduced. + +## Discount configuration and existing data + +New documents use `discount: { type: 'percent' | 'fixed', value: number }`. +Percentages must be integers from 1 to 99; fractional percentages are rejected to +avoid percentage subtraction precision errors. Fixed discounts are positive finite +amounts in RUB. Both round the final price up to whole rubles, with a 1 RUB floor. + +The existing `20261013000000-create-promo-code-collections.js` migration creates +indexes only. A minimal read fallback accepts legacy `discountPercent` when +`discount` is absent, without changing stored data. An explicitly present new +configuration takes precedence, even if invalid; it does not fall back silently. +Legacy fractional percentages must be corrected before making new quotes. + +No migration is required for rollout with this fallback. To remove it later, +backfill legacy documents with `discount: { type: 'percent', value: discountPercent }`, +validate the values, then remove `discountPercent`. Existing successful redemptions +remain usable; historical `discountPercent` fields are ignored. + +Checksums now contain `promo: { promoCodeId, originalAmount, finalAmount }`; +`composePayment.paymentAmount` remains unchanged for Garage. Existing master +purchase and card-link checksums remain compatible. Earlier feature-branch promo +checksums with top-level fields are not supported. If that build has already +processed payments on a deployed environment, coordinate in-flight and delayed +callbacks before replacing it; refresh open payment widgets. Stage verification +with real CloudPayments callbacks has not been performed. + +## Unresolved product decision: usage limits + +`maxUses` is a **soft limit** counting successful redemption records, not a +reservation. Concurrent payments can pass the check and then exceed it. Existing +unique indexes permit only one redemption per `(promoCodeId, userId)` and per +`(promoCodeId, workspaceId)`: parallel successful payments sharing either identity +retain one usage record, but both payments must still be fulfilled. + +No atomic reservations or hard-cap semantics are introduced. A hard limit needs +an explicit product decision about in-flight payments and paid-but-over-limit +behavior before implementation. The focused tests document the current race. diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index d4373f47..2d6d9532 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -1,9 +1,5 @@ import express from 'express'; -import { ObjectId, Db } from 'mongodb'; -import { databases } from '../mongo'; -import { checkPromoCodeUsage, recordPromoCodeUsage, validatePromoPayment, PromoPayment } from './promoCodes/redemptions'; -import { findActivePromoCode } from './promoCodes'; -import { applyPaidPlan } from './promoCodes/application'; +import { PromoPayment, validatePromoOperation, validatePromoSubscription } from './promoCodes/redemptions'; import * as telegram from '../utils/telegram'; import { TelegramBotURLs } from '../utils/telegram'; import { @@ -169,19 +165,14 @@ export default class CloudPaymentsWebhooks { return; } - if (data.promoPaymentId) { - await this.checkPromoPayment(req, res, data, workspace, plan); - - return; - } - const recurrentPaymentSettings = data.cloudPayments?.recurrent; /** * The amount will be considered correct if it is equal to the cost of the tariff plan. * Also, the cost will be correct if it is a payment to activate the subscription. */ - const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; + const expectedAmount = data.promo ? data.promo.finalAmount : plan.monthlyCharge; + const isRightAmount = +body.Amount === expectedAmount || (!data.promo && recurrentPaymentSettings?.startDate); if (!isRightAmount) { this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); @@ -189,6 +180,17 @@ export default class CloudPaymentsWebhooks { return; } + if (data.promo) { + try { + validatePromoSubscription(data, plan.monthlyCharge, Boolean(workspace.isDebug)); + await context.factories.promoCodesFactory.ensurePaymentAvailable(this.buildPromoPayment(body, data)); + } catch (error) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] ${String(error)}`, body); + + return; + } + } + /** * Create business operation about creation of subscription */ @@ -201,7 +203,7 @@ export default class CloudPaymentsWebhooks { workspaceId: workspace._id, amount: +body.Amount * PENNY_MULTIPLIER, currency: body.Currency, - userId: member._id, + userId: data.promo ? member.userId : member._id, tariffPlanId: plan._id, }, dtCreated: new Date(), @@ -242,7 +244,7 @@ export default class CloudPaymentsWebhooks { console.log('๐Ÿ’Ž CloudPayments /pay request', body); try { - data = await this.getDataFromRequest(req); + data = await this.getDataFromRequest(req, true); } catch (e) { const error = e as Error; @@ -268,12 +270,6 @@ export default class CloudPaymentsWebhooks { } } - if (data.promoPaymentId) { - await this.finishPromoPayment(req, res, data, true); - - return; - } - let businessOperation; let workspace; let tariffPlan; @@ -294,22 +290,56 @@ export default class CloudPaymentsWebhooks { return; } - try { - if (!businessOperation._id) { - throw new Error('Business operation has no id'); + if (data.promo) { + let payment: PromoPayment; + + try { + payment = this.buildPromoPayment(body, data); + validatePromoOperation(payment, businessOperation); + } catch (error) { + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Invalid promo payment: ${String(error)}`, body); + + return; } - await applyPaidPlan( - this.getPromoDatabase(), workspace._id, tariffPlan._id, - businessOperation._id, businessOperation.dtCreated, - body.SubscriptionId || null, data.isCardLinkOperation - ); - await this.cancelSupersededSubscriptions(workspace._id); + try { + await req.context.factories.promoCodeRedemptionsFactory.redeem(payment); + } catch (error) { + this.sendError(res.status(500), PayCodes.RETRY, `[Billing / Pay] Could not redeem promo code: ${String(error)}`, body); + + return; + } + } + + try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); + + if (!data.isCardLinkOperation) { + await workspace.changePlan(tariffPlan._id); + } + + const subscriptionId = body.SubscriptionId; + + /** + * Cancellation of the current subscription if: + * 1) the user pays manually (the workspace has an active subscription, but the request body does not) + * 2) if payment is made for another subscription (subscriptions id are not equal) + */ + if (workspace.subscriptionId) { + if (!subscriptionId || subscriptionId !== workspace.subscriptionId) { + await this.clientApi.cancelSubscription({ + Id: workspace.subscriptionId, + }); + } + } + + if (subscriptionId) { + await workspace.setSubscriptionId(subscriptionId); + } } catch (e) { const error = e as Error; - this.sendError(res.status(500), PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); return; } @@ -459,7 +489,7 @@ plan monthly charge: ${data.cloudPayments?.recurrent.amount} ${body.Currency}` */ const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined; - await this.sendReceipt(workspace, tariffPlan, userEmail); + await this.sendReceipt(workspace, tariffPlan, userEmail, data.promo ? +body.Amount : tariffPlan.monthlyCharge); let messageText = ''; @@ -511,19 +541,13 @@ subscription id: ${body.SubscriptionId}`; const failReasonLine = `reason: ${body.Reason} (${body.ReasonCode})`; try { - data = await this.getDataFromRequest(req); + data = await this.getDataFromRequest(req, true); } catch (e) { this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request\n${failReasonLine}`, body); return; } - if (data.promoPaymentId) { - await this.finishPromoPayment(req, res, data, false); - - return; - } - let businessOperation; let workspace; let user; @@ -659,13 +683,7 @@ status: ${body.Status}` * Subscription was cancelled via CloudPayments admin panel (or other no-garage way). * Remove subscription id from workspace. */ - await this.getPromoDatabase().collection('workspaces') - .updateOne({ - _id: workspace._id, - subscriptionId: body.Id, - }, { - $unset: { subscriptionId: '' }, - }); + await workspace.setSubscriptionId(null); } catch (e) { const error = e as Error; @@ -815,401 +833,36 @@ status: ${body.Status}` } /** - * Require the accounts database shared by paid-plan and subscription updates. - * - * @returns connected accounts database - */ - 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. + * Map signed promo data and provider fields to a payment for domain validation. * * @param body - provider callback - * @param data - parsed payment data - * @returns signed payment details + * @param data - verified payment identity + * @returns promo payment without HTTP context */ - private getPromoPayment(body: CheckRequest | PayRequest | FailRequest, data: PaymentData): PromoPayment { - if (!data.promoPaymentId || !data.promoCodeId || typeof data.paymentAmount !== 'number' || - typeof data.originalAmount !== 'number' || typeof data.discountPercent !== 'number' || data.isCardLinkOperation) { + private buildPromoPayment(body: CheckRequest | PayRequest, data: PaymentData): PromoPayment { + if (!data.promo || data.isCardLinkOperation) { throw new Error('PROMO_CODE_INVALID_PAYMENT'); } return { - promoPaymentId: data.promoPaymentId, - promoCodeId: data.promoCodeId, - originalAmount: data.originalAmount, - discountPercent: data.discountPercent, + ...data.promo, workspaceId: data.workspaceId, userId: data.userId, tariffPlanId: data.tariffPlanId, - paymentAmount: data.paymentAmount, transactionId: body.TransactionId?.toString() || '', - amount: Number(body.Amount), + paidAmount: Number(body.Amount), currency: body.Currency, }; } - /** - * Check usage and create one pending operation without consuming the code. - * - * @param req - callback request - * @param res - provider response - * @param data - verified payment data - * @param workspace - authorized workspace - * @param plan - selected plan - */ - private async checkPromoPayment( - req: express.Request, - res: express.Response, - data: PaymentData, - workspace: WorkspaceModel, - plan: PlanDBScheme - ): Promise { - try { - const db = this.getPromoDatabase(); - const payment = this.getPromoPayment(req.body, data); - const recurrent = data.cloudPayments?.recurrent; - - if (recurrent && ( - recurrent.amount !== plan.monthlyCharge || - recurrent.period !== 1 || - recurrent.interval !== (workspace.isDebug ? 'Day' : 'Month') || - !data.nextPaymentDate || - Date.parse(recurrent.startDate || '') !== Date.parse(data.nextPaymentDate) - )) { - throw new Error('PROMO_CODE_INVALID_SUBSCRIPTION'); - } - - validatePromoPayment(payment); - const promoCode = await db.collection('promoCodes').findOne({ _id: new ObjectId(payment.promoCodeId) }); - - if (!promoCode) { - throw new Error('PROMO_CODE_INVALID'); - } - - await findActivePromoCode(db, promoCode.value, plan._id); - await checkPromoCodeUsage(db, promoCode._id, new ObjectId(data.userId), workspace._id, payment.transactionId); - - await db.collection('businessOperations').updateOne({ - _id: new ObjectId(payment.promoPaymentId), - transactionId: payment.transactionId, - }, { - $setOnInsert: { - transactionId: payment.transactionId, - type: BusinessOperationType.WorkspacePlanPurchase, - status: BusinessOperationStatus.Pending, - payload: { - workspaceId: workspace._id, - userId: new ObjectId(data.userId), - tariffPlanId: plan._id, - amount: payment.amount * PENNY_MULTIPLIER, - currency: payment.currency, - }, - dtCreated: new Date(), - }, - }, { - upsert: true, - }); - - res.json({ code: CheckCodes.SUCCESS }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const code = message === 'PROMO_CODE_WRONG_AMOUNT' - ? CheckCodes.WRONG_AMOUNT - : CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED; - - this.sendError(res, code, `[Billing / Check] ${message}`, req.body); - } - } - - /** - * Require the operation created for this signed payment attempt. - * - * @param req - callback request - * @param payment - validated promo payment - * @returns matching business operation - */ - private async getPromoBusinessOperation(req: express.Request, payment: PromoPayment): Promise { - const operation = await this.getBusinessOperation(req, payment.transactionId); - const payload = operation.payload; - - if (!operation._id?.equals(new ObjectId(payment.promoPaymentId)) || - operation.type !== BusinessOperationType.WorkspacePlanPurchase || - !('tariffPlanId' in payload) || - payload.workspaceId.toString() !== payment.workspaceId || - payload.userId.toString() !== payment.userId || - payload.tariffPlanId.toString() !== payment.tariffPlanId || - payload.amount !== payment.amount * PENNY_MULTIPLIER || payload.currency !== payment.currency) { - throw new Error('PROMO_CODE_INVALID_PAYMENT'); - } - - return operation; - } - - /** - * Finish only a checked promo transaction. A short lease serializes duplicate - * callbacks; failed handlers release it, and crashed handlers can be retried. - * - * @param req - provider callback - * @param res - provider response - * @param data - verified payment data - * @param succeeded - whether the provider reported a successful payment - */ - private async finishPromoPayment( - req: express.Request, - res: express.Response, - data: PaymentData, - succeeded: boolean - ): Promise { - const token = new ObjectId(); - let operationId: ObjectId | undefined; - - try { - const db = this.getPromoDatabase(); - const payment = this.getPromoPayment(req.body, data); - - validatePromoPayment(payment); - - let operation = await this.getPromoBusinessOperation(req, payment); - const now = new Date(); - const lock = await db.collection('businessOperations').updateOne({ - _id: operation._id, - $or: [ - { promoProcessingUntil: { $exists: false } }, - { promoProcessingUntil: { $lte: now } }, - ], - }, { - $set: { - promoProcessingToken: token, - promoProcessingUntil: new Date(now.getTime() + 5 * 60 * 1000), - }, - }); - - if (lock.modifiedCount !== 1) { - throw new Error('PROMO_PAYMENT_IN_PROGRESS'); - } - - operationId = new ObjectId(payment.promoPaymentId); - operation = await this.getPromoBusinessOperation(req, payment); - - if (!succeeded && operation.status === BusinessOperationStatus.Confirmed) { - res.json({ code: 0 }); - - return; - } - - if (succeeded && operation.status === BusinessOperationStatus.Rejected) { - throw new Error('PROMO_PAYMENT_ALREADY_REJECTED'); - } - - const delivery = await db.collection('businessOperations').findOne({ _id: operationId }); - const isFinished = succeeded - ? operation.status === BusinessOperationStatus.Confirmed && delivery?.promoReceiptSent && delivery?.promoNotificationSent - : operation.status === BusinessOperationStatus.Rejected && delivery?.promoNotificationSent; - - if (isFinished) { - res.json({ code: 0 }); - - return; - } - - const workspace = await this.getWorkspace(req, data.workspaceId); - const user = await this.getUser(req, data.userId); - - if (succeeded) { - const plan = await this.getPlan(req, data.tariffPlanId); - - /** - * A successful provider payment consumes the code even if applying the - * workspace changes fails. The pending operation remains retryable. - */ - if (operation.status !== BusinessOperationStatus.Confirmed) { - await this.updatePromoOperation(db, operationId, token); - await recordPromoCodeUsage(db, payment); - await this.updatePromoOperation(db, operationId, token); - - const ownsBillingPeriod = await applyPaidPlan( - db, workspace._id, plan._id, operationId, operation.dtCreated, - req.body.SubscriptionId || null - ); - - await this.updatePromoOperation(db, operationId, token); - await this.cancelSupersededSubscriptions(workspace._id); - - if (ownsBillingPeriod) { - await this.updatePromoOperation(db, operationId, token); - await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ - type: 'unblock-workspace', - workspaceId: data.workspaceId, - })); - } - - await this.updatePromoOperation(db, operationId, token, { status: BusinessOperationStatus.Confirmed }); - } - - if (data.shouldSaveCard) { - const cardData = this.getCardData(req.body); - - if (cardData) { - await this.updatePromoOperation(db, operationId, token); - await user.saveNewBankCard(cardData); - } - } - - if (!delivery?.promoReceiptSent) { - await this.updatePromoOperation(db, operationId, token); - await this.sendReceipt(workspace, plan, - req.body.IssuerBankCountry === 'RU' ? user.email : undefined, - payment.amount); - await this.updatePromoOperation(db, operationId, token, { promoReceiptSent: true }); - } - } else { - if (operation.status !== BusinessOperationStatus.Rejected) { - await this.updatePromoOperation(db, operationId, token); - const successfulUse = await db.collection('promoCodeRedemptions').findOne({ - promoCodeId: new ObjectId(payment.promoCodeId), - transactionId: payment.transactionId, - }); - - if (successfulUse) { - res.json({ code: 0 }); - - return; - } - - await this.updatePromoOperation(db, operationId, token, { status: BusinessOperationStatus.Rejected }); - } - } - - if (!delivery?.promoNotificationSent) { - const notification: PaymentSuccessNotificationTask | PaymentFailedNotificationTask = succeeded - ? { - type: SenderWorkerTaskType.PaymentSuccess, - payload: { - workspaceId: data.workspaceId, - tariffPlanId: data.tariffPlanId, - userId: data.userId, - }, - } - : { - type: SenderWorkerTaskType.PaymentFailed, - payload: { - workspaceId: data.workspaceId, - reason: ReasonCodesTranscript[(req.body as FailRequest).ReasonCode], - }, - }; - - await this.updatePromoOperation(db, operationId, token); - await sendNotification(user, notification); - await this.updatePromoOperation(db, operationId, token, { promoNotificationSent: true }); - } - - res.json({ code: 0 }); - } catch (error) { - console.error('[Billing / Promo] Callback failed', error); - /** - * Return an error so the provider retries failed database operations. - */ - res.status(500).json({ code: 1 }); - } finally { - if (operationId) { - try { - await this.getPromoDatabase().collection('businessOperations') - .updateOne({ - _id: operationId, - promoProcessingToken: token, - }, { - $unset: { - promoProcessingToken: '', - promoProcessingUntil: '', - }, - }); - } catch (error) { - console.error('[Billing / Promo] Could not release callback lease', error); - } - } - } - } - - /** - * Retry cancellation without touching the subscription selected by a newer payment. - * - * @param workspaceId - workspace with durable pending cancellations - */ - private async cancelSupersededSubscriptions(workspaceId: ObjectId): Promise { - const workspaces = this.getPromoDatabase().collection<{ - _id: ObjectId; - subscriptionId?: string; - subscriptionIdsToCancel?: string[]; - }>('workspaces'); - const workspace = await workspaces.findOne({ _id: workspaceId }); - - for (const subscriptionId of workspace?.subscriptionIdsToCancel || []) { - if (subscriptionId === workspace?.subscriptionId) { - continue; - } - - const cancellation = await this.clientApi.cancelSubscription({ Id: subscriptionId }); - - if (!cancellation.isSuccess()) { - throw new Error(`Could not cancel superseded subscription ${subscriptionId}`); - } - - await workspaces.updateOne({ _id: workspaceId }, { - $pull: { subscriptionIdsToCancel: subscriptionId }, - }); - } - } - - /** - * 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 * * @param req - request with necessary data + * @param allowExpiredPromo - accept a delayed completion callback for a promo payment * @returns signed payment data or identity resolved from an existing subscription */ - private async getDataFromRequest(req: express.Request): Promise { + private async getDataFromRequest(req: express.Request, allowExpiredPromo = false): Promise { const context = req.context; const body: CheckRequest | PayRequest | FailRequest = req.body; @@ -1219,28 +872,17 @@ status: ${body.Status}` */ if (body.Data) { const parsedData = JSON.parse(body.Data || '{}') as WebhookData; - const isCompletion = req.path === '/pay' || req.path === '/fail'; - const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, isCompletion); - - if ('promoPaymentId' in verifiedData && verifiedData.promoPaymentId) { - /** - * Never override signed promo identity or price with unsigned widget data. - */ - return { - ...verifiedData, - isCardLinkOperation: false, - cloudPayments: parsedData.cloudPayments, - }; - } + const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, allowExpiredPromo); + /* + * Only subscription settings come from the widget; identity and price must remain signed. + */ return { + tariffPlanId: '', + shouldSaveCard: false, + isCardLinkOperation: false, ...verifiedData, - ...parsedData, - promoPaymentId: undefined, - promoCodeId: undefined, - originalAmount: undefined, - discountPercent: undefined, - paymentAmount: undefined, + cloudPayments: parsedData.cloudPayments, }; } diff --git a/src/billing/promoCodes/application.ts b/src/billing/promoCodes/application.ts deleted file mode 100644 index 66fe46ac..00000000 --- a/src/billing/promoCodes/application.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Db, ObjectId } from 'mongodb'; - -/** - * Apply a payment's billing period and subscription atomically, without resetting - * usage on a retry. Preserve superseded subscriptions until cancellation succeeds. - * - * @param db - accounts database - * @param workspaceId - paid workspace - * @param planId - paid plan - * @param operationId - stable business operation identifier - * @param paymentDate - stable operation creation time - * @param subscriptionId - new subscription, or null for a one-time payment - * @param isCardLinkOperation - replace the subscription without restarting the paid period - * @returns whether this payment still owns the workspace billing state - */ -export async function applyPaidPlan( - db: Db, - workspaceId: ObjectId, - planId: ObjectId, - operationId: ObjectId, - paymentDate: Date, - subscriptionId: string | null = null, - isCardLinkOperation = false -): Promise { - if (!(paymentDate instanceof Date) || !Number.isFinite(paymentDate.getTime())) { - throw new Error('PROMO_PAYMENT_INVALID_DATE'); - } - - /** - * Linking a card must not supersede a paid billing period whose callback is delayed. - */ - const paymentIdField = isCardLinkOperation ? 'cardLinkPaymentId' : 'billingPaymentId'; - const paymentDateField = isCardLinkOperation ? 'cardLinkPaymentDate' : 'billingPaymentDate'; - const workspaces = db.collection('workspaces'); - const result = await workspaces.updateOne({ - _id: workspaceId, - [paymentIdField]: { $ne: operationId }, - $and: [ - { - $or: [ - { lastChargeDate: { $exists: false } }, - { lastChargeDate: { $lte: paymentDate } }, - ], - }, - { - $or: [ - { [paymentDateField]: { $exists: false } }, - { [paymentDateField]: { $lt: paymentDate } }, - { - [paymentDateField]: paymentDate, - [paymentIdField]: { $lt: operationId }, - }, - ], - }, - ], - }, [ - { - $set: { - [paymentIdField]: { $literal: operationId }, - [paymentDateField]: { $literal: paymentDate }, - subscriptionIdsToCancel: { - $setUnion: [ - { - $setDifference: [ - { $ifNull: ['$subscriptionIdsToCancel', [] ] }, - { $literal: subscriptionId ? [ subscriptionId ] : [] }, - ], - }, - { - $cond: [ - { - $and: [ - { $ne: [ { $ifNull: ['$subscriptionId', null] }, null] }, - { $ne: ['$subscriptionId', { $literal: subscriptionId } ] }, - ], - }, - [ '$subscriptionId' ], - [], - ], - }, - ], - }, - subscriptionId: subscriptionId ? { $literal: subscriptionId } : '$$REMOVE', - ...(!isCardLinkOperation ? { - tariffPlanId: { $literal: planId }, - billingPeriodEventsCount: 0, - lastChargeDate: { $literal: paymentDate }, - } : {}), - }, - }, - ]); - - if (result.matchedCount === 1) { - return true; - } - - const workspace = await workspaces.findOne({ _id: workspaceId }); - - if (!workspace) { - throw new Error('Workspace not found'); - } - - const ownsBillingPeriod = workspace[paymentIdField]?.equals(operationId) === true && - workspace[paymentDateField]?.getTime() === paymentDate.getTime() && - (isCardLinkOperation - ? (!workspace.lastChargeDate || workspace.lastChargeDate <= paymentDate) && - workspace.subscriptionId === (subscriptionId || undefined) - : workspace.lastChargeDate?.getTime() === paymentDate.getTime()); - - if (!ownsBillingPeriod && subscriptionId) { - /** - * A late paid callback must not leave its superseded subscription charging again. - */ - await workspaces.updateOne({ - _id: workspaceId, - subscriptionId: { $ne: subscriptionId }, - }, { - $addToSet: { subscriptionIdsToCancel: subscriptionId }, - }); - } - - return ownsBillingPeriod; -} diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index 600c99f1..d0c15012 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -1,39 +1,37 @@ -import type { Db } from 'mongodb'; import { ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; +/** + * Discount applied to the current payment, in percent or RUB. + */ +export interface PromoCodeDiscount { + type: 'percent' | 'fixed'; + value: number; +} + /** * Promo code configuration stored in the accounts database. */ export interface PromoCode { - /** - * Identifier referenced by successful redemptions and payment checksums. - */ _id: ObjectId; /** * Uppercase code without surrounding whitespace. */ value: string; - /** - * Percentage discount for the current payment, from 1 to 99. - */ - discountPercent: number; - /** - * Whether the code is unavailable for new payments. - */ + discount: PromoCodeDiscount; isDisabled?: boolean; /** - * Limit on successful uses; missing or invalid values mean unlimited use. + * Soft limit on successful uses; missing or invalid values mean unlimited use. */ maxUses?: number; /** - * Eligible plans; missing means all plans, while an empty array allows none. + * Missing means all plans; an empty array allows none. */ planIds?: ObjectId[]; } /** - * Codes are stored in MongoDB in uppercase, without surrounding whitespace. + * Normalize user input for indexed lookup. * * @param code - user-provided code * @returns normalized code for indexed lookup @@ -61,65 +59,45 @@ export function getMaxUses(value: unknown): number | undefined { } /** - * Reject unsupported discounts before preparing a payment. - * - * @param discountPercent - configured percentage for the current payment - */ -function validateDiscountPercent(discountPercent: number): void { - if (!Number.isFinite(discountPercent) || discountPercent < 1 || discountPercent > 99) { - throw new UserInputError('PROMO_CODE_INVALID'); - } -} - -/** - * Calculate the current payment in whole rubles, rounded up, with a 1 RUB floor. + * Calculate whole rubles, rounded up, with a 1 RUB floor. * + * @param discount - configured percent (integer 1โ€“99) or fixed RUB discount * @param originalAmount - regular plan price in rubles - * @param discountPercent - percentage discount, from 1 to 99 * @returns discounted amount in whole rubles */ -export function calculateDiscountedAmount(originalAmount: number, discountPercent: number): number { +export function applyDiscount(discount: PromoCodeDiscount, originalAmount: number): number { if (!Number.isFinite(originalAmount) || originalAmount <= 0) { throw new UserInputError('PROMO_CODE_INVALID_AMOUNT'); } - validateDiscountPercent(discountPercent); + if (!discount || !Number.isFinite(discount.value) || discount.value <= 0 || + (discount.type !== 'percent' && discount.type !== 'fixed') || + (discount.type === 'percent' && (!Number.isInteger(discount.value) || discount.value > 99))) { + throw new UserInputError('PROMO_CODE_INVALID'); + } - const discountedAmount = originalAmount * (100 - discountPercent) / 100; - const roundedAmount = Math.ceil(discountedAmount); + const discountedAmount = discount.type === 'percent' + ? originalAmount * (100 - discount.value) / 100 + : originalAmount - discount.value; - return Math.max(1, roundedAmount); + return Math.max(1, Math.ceil(discountedAmount)); } /** - * Missing planIds means all plans; a present array allows only its listed plans. - * Usage limits count successful payments only. + * Check current availability and plan restrictions without recalculating a signed price. * - * @param db - accounts database containing promoCodes - * @param code - user-provided code + * @param promoCode - stored promo code, if found * @param tariffPlanId - selected plan - * @returns validated promo code */ -export async function findActivePromoCode(db: Db, code: string, tariffPlanId: ObjectId): Promise { - const promoCode = await db.collection('promoCodes').findOne({ - value: normalizePromoCode(code), - }); - +export function ensureActivePromoCode(promoCode: PromoCode | null, tariffPlanId: ObjectId): asserts promoCode is PromoCode { if (!promoCode || promoCode.isDisabled === true) { throw new UserInputError('PROMO_CODE_INVALID'); } - validateDiscountPercent(promoCode.discountPercent); - if (promoCode.planIds !== undefined && ( !Array.isArray(promoCode.planIds) || !promoCode.planIds.some(id => id instanceof ObjectId && id.equals(tariffPlanId)) )) { throw new UserInputError('PROMO_CODE_INVALID'); } - - return { - ...promoCode, - maxUses: getMaxUses(promoCode.maxUses), - }; } diff --git a/src/billing/promoCodes/redemptions.ts b/src/billing/promoCodes/redemptions.ts index bc7c5a88..c85826e1 100644 --- a/src/billing/promoCodes/redemptions.ts +++ b/src/billing/promoCodes/redemptions.ts @@ -1,49 +1,25 @@ -import type { Db } from 'mongodb'; -import { MongoServerError, ObjectId } from 'mongodb'; +import { ObjectId } from 'mongodb'; import { UserInputError } from 'apollo-server-express'; -import { calculateDiscountedAmount, findActivePromoCode, getMaxUses } from './index'; +import { getMaxUses, PromoCode } from './index'; +import { BusinessOperationDBScheme, BusinessOperationType } from '@hawk.so/types'; +import type { PaymentData } from '../types/paymentData'; /** * Server-calculated price snapshot used to sign a promo payment. */ export interface PromoCodeQuote { - /** - * Identifier of the applied promo code. - */ promoCodeId: ObjectId; - /** - * Full plan price in rubles before the discount. - */ originalAmount: number; - /** - * Discounted price in whole rubles, with a one-ruble minimum. - */ finalAmount: number; - /** - * Percentage discount captured when the quote was prepared. - */ - discountPercent: number; } /** * Successful promo use, unique per code and user or workspace. */ export interface PromoCodeRedemption extends PromoCodeQuote { - /** - * Identifier of the successful usage record. - */ _id: ObjectId; - /** - * User who made the payment. - */ userId: ObjectId; - /** - * Workspace that received the paid plan. - */ workspaceId: ObjectId; - /** - * Plan purchased with the discount. - */ tariffPlanId: ObjectId; /** * Provider transaction identifier used to recognize callback retries. @@ -55,207 +31,121 @@ export interface PromoCodeRedemption extends PromoCodeQuote { * User input and server-resolved payment identity for preparing a quote. */ export interface PromoCodeInput { - /** - * User-provided code, normalized before lookup. - */ promoCode: string; - /** - * Authenticated payer. - */ userId: ObjectId; - /** - * Authorized workspace receiving the plan. - */ workspaceId: ObjectId; - /** - * Selected plan checked against the code's eligibility rules. - */ tariffPlanId: ObjectId; - /** - * Server-resolved full plan price in rubles. - */ originalAmount: number; } /** - * Signed promo identity and price combined with provider callback fields. + * Verified signed identity and prices combined with provider callback fields. */ export interface PromoPayment { - /** - * Promo code identifier from the verified checksum. - */ promoCodeId: string; - /** - * Signed payment-attempt identifier, also used as the business operation ID. - */ - promoPaymentId: string; - /** - * Payer identifier from the verified checksum. - */ + originalAmount: number; + finalAmount: number; userId: string; - /** - * Workspace identifier from the verified checksum. - */ workspaceId: string; - /** - * Purchased plan identifier from the verified checksum. - */ tariffPlanId: string; - /** - * Signed full plan price in rubles. - */ - originalAmount: number; - /** - * Signed percentage discount for the payment. - */ - discountPercent: number; - /** - * Signed discounted amount in rubles. - */ - paymentAmount: number; - /** - * Provider transaction identifier from the callback. - */ transactionId: string; - /** - * Actual callback amount in rubles, compared with the signed quote. - */ - amount: number; - /** - * Provider callback currency; promo payments require RUB. - */ + paidAmount: number; currency: string; } /** - * Check successful uses only. Parallel payments may both pass before either succeeds. + * Check successful uses only, allowing retries of the same successful payment. * - * @param db - accounts database - * @param promoCodeId - selected code - * @param userId - payer - * @param workspaceId - paid workspace - * @param transactionId - same successful callback retry, if present + * @param promoCode - already fetched code + * @param identity - payer and paid workspace + * @param existing - successful use by this payer or workspace, if any + * @param successfulUses - number of successful uses of this code + * @param transactionId - provider transaction on callback retries */ -export async function checkPromoCodeUsage( - db: Db, - promoCodeId: ObjectId, - userId: ObjectId, - workspaceId: ObjectId, +export function ensurePromoCodeAvailable( + promoCode: PromoCode, + identity: Pick, + existing: PromoCodeRedemption | null, + successfulUses: number, transactionId?: string -): Promise { - const redemptions = db.collection('promoCodeRedemptions'); - const existing = await redemptions.findOne({ - promoCodeId, - $or: [ { userId }, { workspaceId } ], - }); - +): void { if (existing) { - const isSameSuccessfulPayment = transactionId && - existing.transactionId === transactionId && - existing.userId.equals(userId) && - existing.workspaceId.equals(workspaceId); - - if (isSameSuccessfulPayment) { + if (transactionId && existing.transactionId === transactionId && + existing.userId.equals(identity.userId) && existing.workspaceId.equals(identity.workspaceId)) { return; } throw new UserInputError('PROMO_CODE_ALREADY_USED'); } - const promoCode = await db.collection('promoCodes').findOne({ _id: promoCodeId }); - - if (!promoCode) { - throw new UserInputError('PROMO_CODE_INVALID'); - } - const maxUses = getMaxUses(promoCode.maxUses); - if (maxUses !== undefined && await redemptions.countDocuments({ promoCodeId }) + 1 > maxUses) { + /* + * shortcut: maxUses is a soft limit; parallel successes may exceed it, revisit only after product decides hard-cap semantics. + */ + if (maxUses !== undefined && successfulUses + 1 > maxUses) { throw new UserInputError('PROMO_CODE_INVALID'); } } /** - * Calculate a signed quote without writing a usage record. + * Validate the signed final amount against the provider payment, never recomputing discounts. * - * @param db - accounts database - * @param input - requested payment - * @returns discounted quote - */ -export async function preparePromoCode(db: Db, input: PromoCodeInput): Promise { - const promoCode = await findActivePromoCode(db, input.promoCode, input.tariffPlanId); - const finalAmount = calculateDiscountedAmount(input.originalAmount, promoCode.discountPercent); - - await checkPromoCodeUsage(db, promoCode._id, input.userId, input.workspaceId); - - return { - promoCodeId: promoCode._id, - originalAmount: input.originalAmount, - finalAmount, - discountPercent: promoCode.discountPercent, - }; -} - -/** - * Validate the signed quote against the successful payment callback. - * - * @param payment - signed identity and provider callback fields + * @param payment - verified signed identity and provider callback fields */ export function validatePromoPayment(payment: PromoPayment): void { - const ids = [payment.promoCodeId, payment.promoPaymentId, payment.userId, payment.workspaceId, payment.tariffPlanId]; + const ids = [payment.promoCodeId, payment.userId, payment.workspaceId, payment.tariffPlanId]; if (ids.some(id => typeof id !== 'string' || !ObjectId.isValid(id)) || typeof payment.transactionId !== 'string' || !payment.transactionId.trim() || payment.currency !== 'RUB') { throw new UserInputError('PROMO_CODE_INVALID_PAYMENT'); } - const expectedAmount = calculateDiscountedAmount(payment.originalAmount, payment.discountPercent); - - if (payment.paymentAmount !== expectedAmount || payment.amount !== expectedAmount) { + if (!Number.isFinite(payment.originalAmount) || payment.originalAmount <= 0 || + !Number.isSafeInteger(payment.finalAmount) || payment.finalAmount < 1 || + payment.finalAmount > Math.max(1, Math.ceil(payment.originalAmount)) || + payment.paidAmount !== payment.finalAmount) { throw new UserInputError('PROMO_CODE_WRONG_AMOUNT'); } } /** - * Record promo usage only after the provider reports a successful payment. + * Reject callbacks that do not match the purchase accepted by /check. * - * @param db - accounts database - * @param payment - verified successful payment + * @param payment - verified payment + * @param operation - purchase loaded by the provider transaction ID */ -export async function recordPromoCodeUsage(db: Db, payment: PromoPayment): Promise { +export function validatePromoOperation(payment: PromoPayment, operation: Pick): void { validatePromoPayment(payment); + const payload = operation.payload; + + if (operation.transactionId !== payment.transactionId || + operation.type !== BusinessOperationType.WorkspacePlanPurchase || + !('tariffPlanId' in payload) || + payload.workspaceId.toString() !== payment.workspaceId || + payload.userId.toString() !== payment.userId || + payload.tariffPlanId.toString() !== payment.tariffPlanId || + payload.amount !== payment.paidAmount * 100 || payload.currency !== payment.currency) { + throw new UserInputError('PROMO_CODE_INVALID_PAYMENT'); + } +} - const redemptions = db.collection('promoCodeRedemptions'); - const usage: PromoCodeRedemption = { - _id: new ObjectId(), - promoCodeId: new ObjectId(payment.promoCodeId), - userId: new ObjectId(payment.userId), - workspaceId: new ObjectId(payment.workspaceId), - tariffPlanId: new ObjectId(payment.tariffPlanId), - transactionId: payment.transactionId, - originalAmount: payment.originalAmount, - finalAmount: payment.paymentAmount, - discountPercent: payment.discountPercent, - }; - - try { - await redemptions.insertOne(usage); - } catch (error) { - if (!(error instanceof MongoServerError) || error.code !== 11000) { - throw error; - } - - const existing = await redemptions.findOne({ - promoCodeId: usage.promoCodeId, - $or: [ { userId: usage.userId }, { workspaceId: usage.workspaceId } ], - }); - - if (!existing) { - throw error; - } - - if (existing.transactionId !== payment.transactionId) { - console.warn('Promo code was used by a parallel successful payment', payment.transactionId); - } +/** + * Keep future subscription charges at full price after a discounted first payment. + * + * @param data - signed payment data and widget subscription settings + * @param monthlyCharge - current full plan price + * @param isDebug - whether the workspace uses daily billing + */ +export function validatePromoSubscription(data: PaymentData, monthlyCharge: number, isDebug: boolean): void { + const recurrent = data.cloudPayments?.recurrent; + + if (recurrent && ( + recurrent.amount !== monthlyCharge || recurrent.period !== 1 || + recurrent.interval !== (isDebug ? 'Day' : 'Month') || + !data.nextPaymentDate || + Date.parse(recurrent.startDate || '') !== Date.parse(data.nextPaymentDate) + )) { + throw new UserInputError('PROMO_CODE_INVALID_SUBSCRIPTION'); } } diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 7abe603e..29a04194 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -1,3 +1,21 @@ +/** + * Server-signed price for a single discounted payment. + */ +export interface PromoPaymentData { + /** + * Applied promo code identifier. + */ + promoCodeId: string; + /** + * Full plan price in rubles. + */ + originalAmount: number; + /** + * Amount to charge in rubles. + */ + finalAmount: number; +} + /** * Data for setting up recurring payments */ @@ -64,27 +82,11 @@ export interface PaymentData { */ isCardLinkOperation: boolean; /** - * Signed amount of the current promo payment in rubles - */ - paymentAmount?: number; - /** - * Signed promo payment attempt, also used as the business operation ID. - */ - promoPaymentId?: string; - /** - * Signed identifier of the promo code applied to this payment. - */ - promoCodeId?: string; - /** - * Signed full plan price in rubles before the discount. - */ - originalAmount?: number; - /** - * Signed percentage discount captured when the quote was prepared. + * Price snapshot present only for promo payments. */ - discountPercent?: number; + promo?: PromoPaymentData; /** - * Signed start date for future full-price payments + * Next scheduled subscription payment date. */ nextPaymentDate?: string; } diff --git a/src/billing/types/response.ts b/src/billing/types/response.ts index f5da282c..a3373b34 100644 --- a/src/billing/types/response.ts +++ b/src/billing/types/response.ts @@ -79,6 +79,10 @@ export enum PayCodes { * Payment registered */ SUCCESS = 0, + /** + * Retry a promo callback whose successful usage could not be persisted. + */ + RETRY = 1, } /** diff --git a/src/index.ts b/src/index.ts index c60d2bfe..36b695ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import ProjectsFactory from './models/projectsFactory'; import { NonCriticalError } from './errors'; import PlansFactory from './models/plansFactory'; import BusinessOperationsFactory from './models/businessOperationsFactory'; +import PromoCodesFactory from './models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from './models/promoCodeRedemptionsFactory'; import schema from './schema'; import { graphqlUploadExpress } from 'graphql-upload'; import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics'; @@ -173,6 +175,13 @@ class HawkAPI { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const releasesFactory = new ReleasesFactory(mongo.databases.events!); + /* eslint-disable @typescript-eslint/no-non-null-assertion + */ + const promoCodeRedemptionsFactory = new PromoCodeRedemptionsFactory(mongo.databases.hawk!); + const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!, promoCodeRedemptionsFactory); + /* eslint-enable @typescript-eslint/no-non-null-assertion + */ + return { usersFactory, workspacesFactory, @@ -180,6 +189,8 @@ class HawkAPI { plansFactory, businessOperationsFactory, releasesFactory, + promoCodesFactory, + promoCodeRedemptionsFactory, }; } diff --git a/src/models/promoCodeRedemptionsFactory.ts b/src/models/promoCodeRedemptionsFactory.ts new file mode 100644 index 00000000..6c3350f4 --- /dev/null +++ b/src/models/promoCodeRedemptionsFactory.ts @@ -0,0 +1,89 @@ +import { Collection, Db, MongoServerError, ObjectId } from 'mongodb'; +import { getMaxUses, PromoCode } from '../billing/promoCodes'; +import { + ensurePromoCodeAvailable, + PromoCodeInput, + PromoCodeRedemption, + PromoPayment, + validatePromoPayment +} from '../billing/promoCodes/redemptions'; + +/** + * Successful promo usage, protected by the existing user and workspace unique indexes. + */ +export default class PromoCodeRedemptionsFactory { + private readonly collection: Collection; + + /** + * Create a successful usage factory. + * + * @param dbConnection - accounts database + */ + constructor(dbConnection: Db) { + this.collection = dbConnection.collection('promoCodeRedemptions'); + } + + /** + * Read successful usage without fetching the code again or reserving capacity. + * + * @param promoCode - already fetched code + * @param identity - payer and paid workspace + * @param transactionId - provider transaction on callback retries + */ + public async ensurePromoCodeAvailable( + promoCode: PromoCode, + identity: Pick, + transactionId?: string + ): Promise { + const existing = await this.collection.findOne({ + promoCodeId: promoCode._id, + $or: [ { userId: identity.userId }, { workspaceId: identity.workspaceId } ], + }); + const successfulUses = !existing && getMaxUses(promoCode.maxUses) !== undefined + ? await this.collection.countDocuments({ promoCodeId: promoCode._id }) + : 0; + + ensurePromoCodeAvailable(promoCode, identity, existing, successfulUses, transactionId); + } + + /** + * Record a successful provider payment; do not reject money already paid on a limit race. + * + * @param payment - verified successful payment + */ + public async redeem(payment: PromoPayment): Promise { + validatePromoPayment(payment); + + const usage: PromoCodeRedemption = { + _id: new ObjectId(), + promoCodeId: new ObjectId(payment.promoCodeId), + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + tariffPlanId: new ObjectId(payment.tariffPlanId), + transactionId: payment.transactionId, + originalAmount: payment.originalAmount, + finalAmount: payment.finalAmount, + }; + + try { + await this.collection.insertOne(usage); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) { + throw error; + } + + const existing = await this.collection.findOne({ + promoCodeId: usage.promoCodeId, + $or: [ { userId: usage.userId }, { workspaceId: usage.workspaceId } ], + }); + + if (!existing) { + throw error; + } + + if (existing.transactionId !== payment.transactionId) { + console.warn('Promo code was used by a parallel successful payment', payment.transactionId); + } + } + } +} diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts new file mode 100644 index 00000000..ec834bde --- /dev/null +++ b/src/models/promoCodesFactory.ts @@ -0,0 +1,86 @@ +import { Collection, Db, Filter, ObjectId } from 'mongodb'; +import { applyDiscount, ensureActivePromoCode, normalizePromoCode, PromoCode } from '../billing/promoCodes'; +import { PromoCodeInput, PromoCodeQuote, PromoPayment, validatePromoPayment } from '../billing/promoCodes/redemptions'; +import PromoCodeRedemptionsFactory from './promoCodeRedemptionsFactory'; + +/** + * Promo code lookup and payment availability checks. + */ +export default class PromoCodesFactory { + private readonly collection: Collection; + private readonly redemptions: PromoCodeRedemptionsFactory; + + /** + * Create a promo code factory. + * + * @param dbConnection - accounts database + * @param redemptions - successful usage factory shared with the request context + */ + constructor(dbConnection: Db, redemptions: PromoCodeRedemptionsFactory) { + this.collection = dbConnection.collection('promoCodes'); + this.redemptions = redemptions; + } + + /** + * Calculate a quote without reserving or recording usage. + * + * @param input - requested payment with server-resolved identity and price + */ + public async calculatePromoQuote(input: PromoCodeInput): Promise { + const promoCode = await this.findActivePromoCode({ value: normalizePromoCode(input.promoCode) }, input.tariffPlanId); + const finalAmount = applyDiscount(promoCode.discount, input.originalAmount); + + await this.redemptions.ensurePromoCodeAvailable(promoCode, input); + + return { + promoCodeId: promoCode._id, + originalAmount: input.originalAmount, + finalAmount, + }; + } + + /** + * Check current restrictions using a single code lookup and the signed price. + * + * @param payment - verified signed quote and provider callback fields + */ + public async ensurePaymentAvailable(payment: PromoPayment): Promise { + validatePromoPayment(payment); + + const promoCode = await this.findActivePromoCode({ _id: new ObjectId(payment.promoCodeId) }, new ObjectId(payment.tariffPlanId)); + + await this.redemptions.ensurePromoCodeAvailable(promoCode, { + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + }, payment.transactionId); + } + + /** + * Read legacy percentage documents without changing stored data. + * + * @param query - indexed value or identifier lookup + * @param tariffPlanId - selected plan + */ + private async findActivePromoCode(query: Filter, tariffPlanId: ObjectId): Promise { + const promoCode = await this.collection.findOne(query); + + ensureActivePromoCode(promoCode, tariffPlanId); + + /* + * shortcut: support discountPercent until existing documents have been migrated to discount. + */ + const legacy = promoCode as PromoCode & { discountPercent?: number }; + + if (promoCode.discount === undefined && typeof legacy.discountPercent === 'number') { + return { + ...promoCode, + discount: { + type: 'percent', + value: legacy.discountPercent, + }, + }; + } + + return promoCode; + } +} diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index cef7df25..109a44ae 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -9,8 +9,7 @@ import { } from '@hawk.so/types'; import checksumService from '../utils/checksumService'; import { ObjectId } from 'mongodb'; -import { databases } from '../mongo'; -import { preparePromoCode, PromoCodeQuote } from '../billing/promoCodes/redemptions'; +import type { PromoCodeQuote } from '../billing/promoCodes/redemptions'; import { UserInputError } from 'apollo-server-express'; import cloudPaymentsApi, { CloudPaymentsJsonData } from '../utils/cloudPaymentsApi'; import * as telegram from '../utils/telegram'; @@ -166,11 +165,7 @@ export default { throw new UserInputError('PROMO_CODE_ADMIN_REQUIRED'); } - if (!databases.hawk) { - throw new Error('Accounts database is not connected'); - } - - promoQuote = await preparePromoCode(databases.hawk, { + promoQuote = await factories.promoCodesFactory.calculatePromoQuote({ promoCode, userId: new ObjectId(user.id), workspaceId: workspace._id, @@ -208,11 +203,11 @@ export default { nextPaymentDate: nextPaymentDate.toISOString(), ...(promoQuote ? { - paymentAmount, - promoPaymentId: new ObjectId().toString(), - promoCodeId: promoQuote.promoCodeId.toString(), - originalAmount: promoQuote.originalAmount, - discountPercent: promoQuote.discountPercent, + promo: { + promoCodeId: promoQuote.promoCodeId.toString(), + originalAmount: promoQuote.originalAmount, + finalAmount: promoQuote.finalAmount, + }, } : {}), }; @@ -336,6 +331,10 @@ debug: ${Boolean(workspace.isDebug)}` throw new UserInputError('Invalid checksum'); } + if (paymentData.promo) { + throw new UserInputError('PROMO_CODE_NOT_AVAILABLE_FOR_SAVED_CARD'); + } + const fullUserInfo = await factories.usersFactory.findById(user.id); const workspace = await factories.workspacesFactory.findById(paymentData.workspaceId); diff --git a/src/types/graphql.ts b/src/types/graphql.ts index cd0a5f36..aae1fd62 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -6,6 +6,8 @@ import ProjectsFactory from '../models/projectsFactory'; import PlansFactory from '../models/plansFactory'; import BusinessOperationsFactory from '../models/businessOperationsFactory'; import ReleasesFactory from '../models/releasesFactory'; +import PromoCodesFactory from '../models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from '../models/promoCodeRedemptionsFactory'; /** * Resolver's Context argument @@ -92,6 +94,16 @@ export interface ContextFactories { * Releases factory for working with releases */ releasesFactory: ReleasesFactory; + + /** + * Promo quote calculation and payment availability. + */ + promoCodesFactory: PromoCodesFactory; + + /** + * Successful promo usage and callback deduplication. + */ + promoCodeRedemptionsFactory: PromoCodeRedemptionsFactory; } /** diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 1bafb2a2..c809a021 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'; +import type { PromoPaymentData } from '../billing/types/paymentData'; /** * Signed identity for a plan purchase or a card-linking payment. @@ -31,25 +31,9 @@ interface PlanPurchaseChecksumData { */ nextPaymentDate: string; /** - * Discounted amount for the current payment in rubles + * Price snapshot present only for promo payments. */ - paymentAmount?: number; - /** - * Signed payment attempt, also used as the business operation ID. - */ - promoPaymentId?: string; - /** - * Identifier of the applied promo code. - */ - promoCodeId?: string; - /** - * Full plan price in rubles before the discount. - */ - originalAmount?: number; - /** - * Percentage discount captured when the quote was prepared. - */ - discountPercent?: number; + promo?: PromoPaymentData; } /** @@ -100,11 +84,7 @@ class ChecksumService { const payload = jwt.verify(checksum, secret, { ignoreExpiration: allowExpiredPromo, }) as ChecksumData; - const isPromoPayment = 'promoPaymentId' in payload && Boolean(payload.promoPaymentId); - - if ('promoCodeRedemptionId' in payload) { - throw new UserInputError('PROMO_CODE_INVALID'); - } + const isPromoPayment = 'promo' in payload && Boolean(payload.promo); if (allowExpiredPromo && !isPromoPayment) { jwt.verify(checksum, secret); @@ -124,15 +104,7 @@ class ChecksumService { tariffPlanId: payload.tariffPlanId, shouldSaveCard: payload.shouldSaveCard, nextPaymentDate: payload.nextPaymentDate, - ...(payload.promoPaymentId - ? { - paymentAmount: payload.paymentAmount, - promoPaymentId: payload.promoPaymentId, - promoCodeId: payload.promoCodeId, - originalAmount: payload.originalAmount, - discountPercent: payload.discountPercent, - } - : {}), + ...(payload.promo ? { promo: payload.promo } : {}), }; } } diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 1db61bec..e3d7e3e2 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -158,6 +158,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -179,6 +181,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories, (req) => { @@ -200,6 +204,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -219,6 +225,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -241,6 +249,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -267,6 +277,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -301,6 +313,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -335,6 +349,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -390,6 +406,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -411,6 +429,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -434,6 +454,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -465,6 +487,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -498,6 +522,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -535,6 +561,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -573,6 +601,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -613,6 +643,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -648,6 +680,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -692,6 +726,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -750,6 +786,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -845,6 +883,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); @@ -915,6 +955,8 @@ describe('GitHub Routes - /integration/github/connect', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; setupRouter(factories); diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts index e1ffc6a9..a6504f44 100644 --- a/test/resolvers/billingNew.test.ts +++ b/test/resolvers/billingNew.test.ts @@ -82,6 +82,8 @@ function createComposePaymentTestSetup(options: { projectsFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }, }; diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index ea894eb0..4f3c7f54 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -125,6 +125,8 @@ function createMockContext(mockProject: ReturnType): R plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }, }; } diff --git a/test/services/promoCodes/application.test.ts b/test/services/promoCodes/application.test.ts deleted file mode 100644 index bee855a4..00000000 --- a/test/services/promoCodes/application.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Db, MongoClient, ObjectId } from 'mongodb'; -import { applyPaidPlan } from '../../../src/billing/promoCodes/application'; - -const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); - -/** - * Ordinary and promo callbacks must use the same atomic billing transition. - */ -describe('Paid 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 result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(true); - expect(workspace).toMatchObject({ - tariffPlanId: planId, - billingPeriodEventsCount: 0, - lastChargeDate: PAYMENT_DATE, - billingPaymentDate: PAYMENT_DATE, - billingPaymentId: operationId, - }); - }); - - test('should not reset usage or advance the billing date when the payment is retried', async () => { - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); - await db.collection('workspaces').updateOne({ _id: workspaceId }, { - $set: { billingPeriodEventsCount: 7 }, - }); - - const result = await applyPaidPlan(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 applyPaidPlan(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 applyPaidPlan(db, workspaceId, newerPlanId, newerOperationId, newerDate, 'sub-new'); - - const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(false); - expect(workspace).toMatchObject({ - tariffPlanId: newerPlanId, - lastChargeDate: newerDate, - billingPaymentId: newerOperationId, - subscriptionId: 'sub-new', - subscriptionIdsToCancel: [ 'sub-old' ], - }); - }); - - test('should reject old ownership when a later billing update leaves the payment marker unchanged', async () => { - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); - const newerDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); - - await db.collection('workspaces').updateOne({ _id: workspaceId }, { - $set: { - lastChargeDate: newerDate, - subscriptionId: 'sub-new', - billingPeriodEventsCount: 7, - }, - }); - - const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-old'); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(false); - expect(workspace?.subscriptionId).toBe('sub-new'); - expect(workspace?.billingPeriodEventsCount).toBe(7); - expect(workspace?.lastChargeDate).toEqual(newerDate); - }); - - test('should preserve usage after concurrent callbacks for the same payment', async () => { - const requests = [operationId, operationId]; - const results = await Promise.all(requests.map(id => applyPaidPlan(db, workspaceId, planId, id, PAYMENT_DATE))); - - await db.collection('workspaces').updateOne({ _id: workspaceId }, { - $set: { billingPeriodEventsCount: 7 }, - }); - await applyPaidPlan(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?.billingPaymentId).toEqual(operationId); - }); - - test('should atomically replace the subscription and retain pending cancellations on retry', async () => { - await db.collection('workspaces').updateOne({ _id: workspaceId }, { - $set: { subscriptionId: 'sub-old' }, - }); - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-new'); - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-new'); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(workspace).toMatchObject({ - tariffPlanId: planId, - subscriptionId: 'sub-new', - subscriptionIdsToCancel: [ 'sub-old' ], - }); - }); - - test('should remove a subscription for a one-time payment without losing its cancellation', async () => { - await db.collection('workspaces').updateOne({ _id: workspaceId }, { - $set: { subscriptionId: 'sub-old' }, - }); - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(workspace).not.toHaveProperty('subscriptionId'); - expect(workspace?.subscriptionIdsToCancel).toEqual([ 'sub-old' ]); - }); - - test('should keep the paid period and usage when only linking a card', async () => { - const before = await db.collection('workspaces').findOne({ _id: workspaceId }); - - await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-card', true); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(workspace?.tariffPlanId).toEqual(before?.tariffPlanId); - expect(workspace?.lastChargeDate).toEqual(before?.lastChargeDate); - expect(workspace?.billingPeriodEventsCount).toBe(50); - expect(workspace?.subscriptionId).toBe('sub-card'); - }); - - test('a newer card link must not prevent applying an earlier successful plan purchase', async () => { - const cardLinkId = new ObjectId(); - const cardLinkDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); - - await applyPaidPlan(db, workspaceId, planId, cardLinkId, cardLinkDate, 'sub-card', true); - const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-paid'); - const retry = await applyPaidPlan(db, workspaceId, planId, cardLinkId, cardLinkDate, 'sub-card', true); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(true); - expect(retry).toBe(false); - expect(workspace).toMatchObject({ - tariffPlanId: planId, - lastChargeDate: PAYMENT_DATE, - billingPaymentId: operationId, - subscriptionId: 'sub-paid', - subscriptionIdsToCancel: [ 'sub-card' ], - }); - }); - - test('an old card link must not replace a newer paid subscription', async () => { - const newerDate = new Date(PAYMENT_DATE.getTime() + 60 * 60 * 1000); - const paymentId = new ObjectId(); - - await applyPaidPlan(db, workspaceId, planId, paymentId, newerDate, 'sub-paid'); - const result = await applyPaidPlan(db, workspaceId, planId, operationId, PAYMENT_DATE, 'sub-card', true); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(false); - expect(workspace?.subscriptionId).toBe('sub-paid'); - expect(workspace?.billingPaymentId).toEqual(paymentId); - expect(workspace?.subscriptionIdsToCancel).toEqual([ 'sub-card' ]); - }); - - test('payments checked at the same time must use their operation id as a stable tie breaker', async () => { - const olderId = new ObjectId('000000000000000000000001'); - const newerId = new ObjectId('000000000000000000000002'); - - await applyPaidPlan(db, workspaceId, planId, newerId, PAYMENT_DATE, 'sub-new'); - const result = await applyPaidPlan(db, workspaceId, new ObjectId(), olderId, PAYMENT_DATE, 'sub-old'); - const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - - expect(result).toBe(false); - expect(workspace?.tariffPlanId).toEqual(planId); - expect(workspace?.subscriptionId).toBe('sub-new'); - }); - - test('should reject a missing workspace', async () => { - const result = applyPaidPlan(db, new ObjectId(), 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 result = applyPaidPlan(db, workspaceId, planId, operationId, new Date('invalid')); - - 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).not.toHaveProperty('billingPaymentId'); - }); -}); diff --git a/test/services/promoCodes/billingNew.test.ts b/test/services/promoCodes/billingNew.test.ts new file mode 100644 index 00000000..21df8ce8 --- /dev/null +++ b/test/services/promoCodes/billingNew.test.ts @@ -0,0 +1,223 @@ +import { ObjectId } from 'mongodb'; +import jwt from 'jsonwebtoken'; +import billingNew from '../../../src/resolvers/billingNew'; +import type { ResolverContextWithUser } from '../../../src/types/graphql'; +import checksumService from '../../../src/utils/checksumService'; +import cloudPaymentsApi from '../../../src/utils/cloudPaymentsApi'; +import { databases } from '../../../src/mongo'; + +jest.mock('../../../src/metrics', () => ({ + setupMongoMetrics: jest.fn(), + withMongoMetrics: (options: unknown): unknown => options, +})); +jest.mock('../../../src/utils/telegram', () => ({ + ...jest.requireActual('../../../src/utils/telegram'), + sendMessage: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../src/utils/cloudPaymentsApi', () => ({ + __esModule: true, + default: { payByToken: jest.fn() }, +})); +jest.mock('../../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn(() => { + throw new Error('Resolver must use factories, not direct MongoDB access'); + }), + }, + }, +})); + +describe('Promo payment resolver', () => { + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + const secret = 'promo-resolver-tests-only'; + + beforeAll(() => { + process.env.JWT_SECRET_BILLING_CHECKSUM = secret; + }); + + beforeEach(() => jest.clearAllMocks()); + + afterEach(() => { + expect(databases.hawk!.collection).not.toHaveBeenCalled(); + }); + + afterAll(() => { + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + }); + + function setup() { + const userId = new ObjectId(); + const plan = { + _id: new ObjectId(), + name: 'Paid plan', + monthlyCharge: 100, + }; + const workspace = { + _id: new ObjectId(), + name: 'Promo workspace', + tariffPlanId: plan._id, + lastChargeDate: new Date(), + isBlocked: false, + getMemberInfo: jest.fn().mockResolvedValue({ + userId, + isAdmin: true, + }), + isTariffPlanExpired: jest.fn().mockReturnValue(true), + }; + const quote = { + promoCodeId: new ObjectId(), + originalAmount: 100, + finalAmount: 80, + }; + const factories = { + workspacesFactory: { findById: jest.fn().mockResolvedValue(workspace) }, + plansFactory: { findById: jest.fn().mockResolvedValue(plan) }, + usersFactory: { findById: jest.fn() }, + promoCodesFactory: { calculatePromoQuote: jest.fn().mockResolvedValue(quote) }, + promoCodeRedemptionsFactory: { redeem: jest.fn() }, + businessOperationsFactory: { create: jest.fn() }, + }; + const context = { + user: { id: userId.toString() }, + factories, + } as unknown as ResolverContextWithUser; + const input = { + workspaceId: workspace._id.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: true, + promoCode: ' Promo ', + }; + + return { + userId, + workspace, + plan, + quote, + factories, + context, + input, + }; + } + + test('composePayment calls the quote factory and signs nested promo while retaining outward paymentAmount', async () => { + const { userId, workspace, plan, quote, factories, context, input } = setup(); + const result = await billingNew.Query.composePayment(undefined, { input }, context); + + expect(factories.promoCodesFactory.calculatePromoQuote).toHaveBeenCalledTimes(1); + expect(factories.promoCodesFactory.calculatePromoQuote).toHaveBeenCalledWith({ + promoCode: 'Promo', + userId, + workspaceId: workspace._id, + tariffPlanId: plan._id, + originalAmount: 100, + }); + expect(result).toMatchObject({ + paymentAmount: 80, + currency: 'RUB', + isCardLinkOperation: false, + plan: { monthlyCharge: 100 }, + }); + const signed = jwt.verify(result.checksum, secret); + const expected = { + workspaceId: workspace._id.toString(), + userId: userId.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: true, + nextPaymentDate: result.nextPaymentDate.toISOString(), + promo: { + promoCodeId: quote.promoCodeId.toString(), + originalAmount: 100, + finalAmount: 80, + }, + }; + + expect(signed).toEqual({ + ...expected, + iat: expect.any(Number), + exp: expect.any(Number), + }); + expect(checksumService.parseAndVerifyChecksum(result.checksum)).toEqual(expected); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + expect(factories.promoCodeRedemptionsFactory.redeem).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, ' '])('no promo (%s) retains ordinary full-price composition', async promoCode => { + const { factories, context, input } = setup(); + const result = await billingNew.Query.composePayment(undefined, { + input: { + ...input, + promoCode, + }, + }, context); + + expect(result).toMatchObject({ + paymentAmount: 100, + isCardLinkOperation: false, + }); + expect(checksumService.parseAndVerifyChecksum(result.checksum)).not.toHaveProperty('promo'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('card linking rejects a promo before obtaining a quote', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.isTariffPlanExpired.mockReturnValue(false); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_NOT_AVAILABLE_FOR_CARD_LINK'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('non-admin members cannot obtain a promo quote', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.getMemberInfo.mockResolvedValue({ + userId: new ObjectId(), + isAdmin: false, + }); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_ADMIN_REQUIRED'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('quote validation errors propagate without creating an operation', async () => { + const { factories, context, input } = setup(); + + factories.promoCodesFactory.calculatePromoQuote.mockRejectedValueOnce(new Error('PROMO_CODE_INVALID')); + await expect(billingNew.Query.composePayment(undefined, { input }, context)).rejects.toThrow('PROMO_CODE_INVALID'); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + expect(factories.promoCodeRedemptionsFactory.redeem).not.toHaveBeenCalled(); + }); + + test.each([false, true])('payWithCard rejects a signed promo before lookup or charging (recurrent: %s)', async isRecurrent => { + const { userId, workspace, plan, quote, factories, context } = setup(); + const checksum = await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: userId.toString(), + tariffPlanId: plan._id.toString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + promo: { + promoCodeId: quote.promoCodeId.toString(), + originalAmount: 100, + finalAmount: 80, + }, + }); + + await expect(billingNew.Mutation.payWithCard(undefined, { + input: { + checksum, + cardId: 'saved-card', + isRecurrent, + }, + }, context)) + .rejects.toThrow('PROMO_CODE_NOT_AVAILABLE_FOR_SAVED_CARD'); + expect(cloudPaymentsApi.payByToken).not.toHaveBeenCalled(); + expect(factories.usersFactory.findById).not.toHaveBeenCalled(); + expect(factories.workspacesFactory.findById).not.toHaveBeenCalled(); + expect(factories.plansFactory.findById).not.toHaveBeenCalled(); + expect(factories.businessOperationsFactory.create).not.toHaveBeenCalled(); + }); +}); diff --git a/test/services/promoCodes/callbacks.test.ts b/test/services/promoCodes/callbacks.test.ts index b505355d..f0a962ff 100644 --- a/test/services/promoCodes/callbacks.test.ts +++ b/test/services/promoCodes/callbacks.test.ts @@ -1,22 +1,28 @@ import type { Request, Response } from 'express'; import { Db, MongoClient, ObjectId } from 'mongodb'; -import type { Document, WithId } from 'mongodb'; -import CloudPaymentsWebhooks from '../../../src/billing/cloudpayments'; -import type { PaymentData } from '../../../src/billing/types/paymentData'; -import { SubscriptionStatus } from '../../../src/billing/types/enums'; +import jwt from 'jsonwebtoken'; +import HawkCatcher from '@hawk.so/nodejs'; import { BusinessOperationStatus, BusinessOperationType } from '@hawk.so/types'; +import CloudPaymentsWebhooks from '../../../src/billing/cloudpayments'; +import { CheckCodes } from '../../../src/billing/types'; +import { ReasonCode, ReasonCodesTranscript, SubscriptionStatus } from '../../../src/billing/types/enums'; import { databases } from '../../../src/mongo'; import DataLoaders from '../../../src/dataLoaders'; import WorkspacesFactory from '../../../src/models/workspacesFactory'; +import WorkspaceModel from '../../../src/models/workspace'; import UsersFactory from '../../../src/models/usersFactory'; import PlansFactory from '../../../src/models/plansFactory'; import BusinessOperationsFactory from '../../../src/models/businessOperationsFactory'; +import PromoCodesFactory from '../../../src/models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from '../../../src/models/promoCodeRedemptionsFactory'; import checksumService from '../../../src/utils/checksumService'; import { publish } from '../../../src/rabbitmq'; import sendNotification from '../../../src/utils/personalNotifications'; +import { sendMessage, TelegramBotURLs } from '../../../src/utils/telegram'; +import { SenderWorkerTaskType } from '../../../src/types/userNotifications'; -/** - * Metrics starts a process-wide cleanup interval; it is unrelated to callback persistence. +/* + * Metrics starts an unrelated process-wide cleanup interval. */ jest.mock('../../../src/metrics', () => ({ setupMongoMetrics: jest.fn(), @@ -45,60 +51,32 @@ jest.mock('cloudpayments', () => ({ })), })); -const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); -const NEWER_PAYMENT_DATE = new Date('2026-10-13T13:00:00.000Z'); - -/** - * Test view exposing callback handlers and mocked external APIs, including private members. +type Callback = 'check' | 'pay' | 'fail' | 'recurrent'; +/* + * Only private visibility is bypassed; handlers, models and factories are real. */ -type TestCallbacks = { - finishPromoPayment(req: Request, res: Response, data: PaymentData, succeeded: boolean): Promise; - pay(req: Request, res: Response): Promise; - recurrent(req: Request, res: Response): Promise; +type TestCallbacks = Record Promise> & { clientApi: { cancelSubscription: jest.Mock }; receiptApi: { createReceipt: jest.Mock }; }; -/** - * Create a barrier for pausing a callback during an external cancellation. - * - * @returns A promise and the function that releases waiting callbacks. - */ -function semaphore(): { promise: Promise; release: () => void } { - let release!: () => void; - const promise = new Promise(resolve => { - release = resolve; - }); - - return { - promise, - release, - }; -} +const SECRET = 'promo-callback-tests-only'; +const PAYMENT_DATE = new Date('2026-10-13T12:00:00.000Z'); -/** - * Create a chainable response mock for inspecting callback acknowledgements. - * - * @returns An Express response with mocked JSON and status methods. - */ -function response(): Response & { json: jest.Mock; status: jest.Mock } { - return { - json: jest.fn().mockReturnThis(), - status: jest.fn().mockReturnThis(), - } as unknown as Response & { json: jest.Mock; status: jest.Mock }; -} - -describe('Promo payment callbacks with MongoDB', () => { +describe('Payment callbacks with MongoDB', () => { let client: MongoClient; let db: Db; let callbacks: TestCallbacks; let workspaceId: ObjectId; let userId: ObjectId; + let workspaceKeys: string[]; let factories: { workspacesFactory: WorkspacesFactory; usersFactory: UsersFactory; plansFactory: PlansFactory; businessOperationsFactory: BusinessOperationsFactory; + promoCodesFactory: PromoCodesFactory; + promoCodeRedemptionsFactory: PromoCodeRedemptionsFactory; }; const previousDatabase = databases.hawk; const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; @@ -110,12 +88,11 @@ describe('Promo payment callbacks with MongoDB', () => { if (!uri) { throw new Error('Jest MongoDB test URI is not configured'); } - client = new MongoClient(uri); await client.connect(); db = client.db('promo-callback-tests'); databases.hawk = db; - process.env.JWT_SECRET_BILLING_CHECKSUM = 'promo-callback-tests-only'; + process.env.JWT_SECRET_BILLING_CHECKSUM = SECRET; }); beforeEach(async () => { @@ -133,29 +110,47 @@ describe('Promo payment callbacks with MongoDB', () => { }, { unique: true }); workspaceId = new ObjectId(); userId = new ObjectId(); - await db.collection('workspaces').insertOne({ + const workspace = { _id: workspaceId, name: 'Callback workspace', tariffPlanId: new ObjectId(), billingPeriodEventsCount: 50, - lastChargeDate: new Date(PAYMENT_DATE.getTime() - 86400000), + lastChargeDate: PAYMENT_DATE, subscriptionId: 'sub-old', - }); + }; + + workspaceKeys = Object.keys(workspace).sort(); + await db.collection('workspaces').insertOne(workspace); await db.collection('users').insertOne({ _id: userId, email: 'callback@example.test', }); + await db.collection(`team:${workspaceId}`).insertOne({ + userId, + isAdmin: true, + }); const loaders = new DataLoaders(db); + const redemptions = new PromoCodeRedemptionsFactory(db); factories = { workspacesFactory: new WorkspacesFactory(db, loaders), usersFactory: new UsersFactory(db, loaders), plansFactory: new PlansFactory(db, loaders), businessOperationsFactory: new BusinessOperationsFactory(db, loaders), + promoCodesFactory: new PromoCodesFactory(db, redemptions), + promoCodeRedemptionsFactory: redemptions, }; callbacks = new CloudPaymentsWebhooks() as unknown as TestCallbacks; }); + afterEach(async () => { + jest.restoreAllMocks(); + /* + * No lease, payment-ordering or pending-cancellation markers belong on the workspace. + */ + expect(Object.keys((await db.collection('workspaces').findOne({ _id: workspaceId }))!).sort()).toEqual(workspaceKeys); + }); + afterAll(async () => { databases.hawk = previousDatabase; if (previousSecret === undefined) { @@ -171,25 +166,10 @@ describe('Promo payment callbacks with MongoDB', () => { } }); - /** - * Persist a pending purchase and prepare its signed payment callback. - * - * @param label - Payment label used for plan, promo code, transaction and subscription fixtures. - * @param date - Creation date of the pending business operation. - * @param promo - Whether to include promo code data and a discounted amount. - * @returns The operation and plan identifiers, decoded payment data and callback request. - */ - async function payment(label: string, date: Date, promo = true): Promise<{ - operationId: ObjectId; - planId: ObjectId; - data: PaymentData; - req: Request; - }> { - const operationId = new ObjectId(); + async function payment(promo = true) { const planId = new ObjectId(); const promoCodeId = new ObjectId(); const amount = promo ? 80 : 100; - const transactionId = label === 'A' ? 1001 : 1002; const signedData = { workspaceId: workspaceId.toHexString(), userId: userId.toHexString(), @@ -197,366 +177,599 @@ describe('Promo payment callbacks with MongoDB', () => { shouldSaveCard: false, nextPaymentDate: '2026-11-13T12:00:00.000Z', ...(promo ? { - promoPaymentId: operationId.toHexString(), - promoCodeId: promoCodeId.toHexString(), - originalAmount: 100, - paymentAmount: amount, - discountPercent: 20, + promo: { + promoCodeId: promoCodeId.toHexString(), + originalAmount: 100, + finalAmount: amount, + }, } : {}), }; await db.collection('plans').insertOne({ _id: planId, - name: `Plan ${label}`, + name: 'Paid plan', monthlyCharge: 100, }); if (promo) { await db.collection('promoCodes').insertOne({ _id: promoCodeId, - value: `PROMO-${label}`, - discountPercent: 20, + value: 'PROMO', + discount: { + type: 'percent', + value: 20, + }, }); } - await db.collection('businessOperations').insertOne({ - _id: operationId, - transactionId: transactionId.toString(), - type: BusinessOperationType.WorkspacePlanPurchase, - status: BusinessOperationStatus.Pending, - dtCreated: date, - payload: { - workspaceId, - userId, - tariffPlanId: planId, - amount: amount * 100, - currency: 'RUB', - }, - }); const checksum = await checksumService.generateChecksum(signedData); - const data: PaymentData = { - ...signedData, - isCardLinkOperation: false, - }; const req = { - path: '/pay', body: { - TransactionId: transactionId, + TransactionId: 1001, Amount: amount.toString(), Currency: 'RUB', - SubscriptionId: `sub-${label}`, + SubscriptionId: 'sub-new', IssuerBankCountry: 'RU', + Reason: 'Declined', + ReasonCode: ReasonCode.DO_NOT_HONOR, Data: JSON.stringify({ checksum }), }, context: { factories }, } as unknown as Request; return { - operationId, planId, - data, + promoCodeId, + signedData, + checksum, req, }; } - /** - * Resume a successful promo payment through the real completion handler. - * - * @param attempt - Persisted purchase and its callback data. - * @returns The response mock containing the callback acknowledgement. + /* + * Isolate completion tests from /check; the end-to-end test below covers their contract. */ - async function finish(attempt: Awaited>): Promise> { - const res = response(); - - /** - * Only the private visibility is bypassed: the callback, plan application and models are real. - */ - await callbacks.finishPromoPayment(attempt.req, res, attempt.data, true); - - return res; + async function pending(attempt: Awaited>, date = PAYMENT_DATE) { + return factories.businessOperationsFactory.create({ + transactionId: attempt.req.body.TransactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + dtCreated: date, + payload: { + workspaceId, + userId, + tariffPlanId: attempt.planId, + amount: Number(attempt.req.body.Amount) * 100, + currency: 'RUB', + }, + }); } - /** - * Process a payment callback and assert a successful acknowledgement. - * - * @param attempt - Persisted purchase with the callback request to process. - * @returns The response mock after successful payment processing. - */ - async function pay(attempt: Awaited>): Promise> { - const res = response(); + async function invoke(method: Callback, req: Request, code = 0): Promise { + const res = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; - await callbacks.pay(attempt.req, res); + await callbacks[method](req, res as unknown as Response); expect(res.status).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ code: 0 }); - - return res; + expect(res.json.mock.calls).toEqual([ [ { code } ] ]); } - /** - * Read the current workspace state after callback processing. - * - * @returns The persisted workspace document, or null if it is absent. - */ - async function workspace(): Promise | null> { - return db.collection('workspaces').findOne({ _id: workspaceId }); + function expectReceipt(amount: number): void { + expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + email: 'callback@example.test', + Items: [ expect.objectContaining({ + amount, + price: amount, + quantity: 1, + }) ], + })); } - /** - * Assert promo payment completion, released processing ownership and a single redemption. - * - * @param attempt - Promo purchase whose persisted completion state should be checked. - * @returns Resolves when all completion assertions pass. - */ - async function expectFinished(attempt: Awaited>): Promise { - expect(await db.collection('businessOperations').findOne({ _id: attempt.operationId })).toMatchObject({ + test('/check uses the usual factory and provider transaction ID without reserving promo usage', async () => { + const attempt = await payment(); + const create = jest.spyOn(factories.businessOperationsFactory, 'create'); + + await invoke('check', attempt.req); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + transactionId: '1001', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: expect.objectContaining({ + workspaceId, + tariffPlanId: attempt.planId, + amount: 8000, + currency: 'RUB', + }), + })); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); + + test('/check creates an operation that the matching /pay can complete', async () => { + const attempt = await payment(); + + await invoke('check', attempt.req); + await invoke('pay', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ status: BusinessOperationStatus.Confirmed, - promoReceiptSent: true, - promoNotificationSent: true, + payload: { userId }, }); - expect(await db.collection('businessOperations').findOne({ _id: attempt.operationId })).not.toHaveProperty('promoProcessingToken'); - expect(await db.collection('promoCodeRedemptions').countDocuments({ - promoCodeId: new ObjectId(attempt.data.promoCodeId), - })).toBe(1); - } + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expectReceipt(80); + }); - test('retry of pending promo A must not cancel or replace subscription B from a later ordinary payment', async () => { - const a = await payment('A', PAYMENT_DATE); - const b = await payment('B', NEWER_PAYMENT_DATE, false); + test('same widget checksum can /check a new transaction after a failed attempt', async () => { + const attempt = await payment(); + + await invoke('check', attempt.req); + await invoke('fail', attempt.req); + attempt.req.body.TransactionId = 1002; + await invoke('check', attempt.req); + expect(JSON.parse(attempt.req.body.Data).checksum).toBe(attempt.checksum); + expect(await db.collection('businessOperations').find({}, { + projection: { + transactionId: 1, + status: 1, + _id: 0, + }, + }) + .sort({ transactionId: 1 }) + .toArray()).toEqual([ + { + transactionId: '1001', + status: BusinessOperationStatus.Rejected, + }, + { + transactionId: '1002', + status: BusinessOperationStatus.Pending, + }, + ]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); - jest.mocked(publish).mockRejectedValueOnce(new Error('limiter publish failed')); - const failed = await finish(a); + test.each([ + ['amount', { Amount: '79' }, CheckCodes.WRONG_AMOUNT], + ['currency', { Currency: 'USD' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ])('/check rejects invalid promo %s', async (_name, changes, code) => { + const attempt = await payment(); + + Object.assign(attempt.req.body, changes); + await invoke('check', attempt.req, code as number); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); - expect(failed.status).toHaveBeenCalledWith(500); - expect(failed.json).toHaveBeenCalledWith({ code: 1 }); - expect(await workspace()).toMatchObject({ - tariffPlanId: a.planId, - lastChargeDate: PAYMENT_DATE, - billingPaymentId: a.operationId, - billingPaymentDate: PAYMENT_DATE, - subscriptionId: 'sub-A', + test.each([ + ['disabled', { isDisabled: true } ], + ['another plan', { planIds: [ new ObjectId() ] } ], + ['no allowed plans', { planIds: [] } ], + ])('/check rejects a promo that is now %s', async (_name, changes) => { + const attempt = await payment(); + + await db.collection('promoCodes').updateOne({ _id: attempt.promoCodeId }, { $set: changes }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test('/check rejects a deleted promo', async () => { + const attempt = await payment(); + + await db.collection('promoCodes').deleteOne({ _id: attempt.promoCodeId }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); + + test.each([ + ['full price', {}, 0], + ['discounted renewal', { amount: 80 }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong interval', { interval: 'Day' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong period', { period: 2 }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ['wrong start date', { startDate: '2026-12-13T12:00:00.000Z' }, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED], + ])('/check validates promo recurrence: %s', async (_name, changes, code) => { + const attempt = await payment(); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + ...changes, + }, + }, }); - expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ - status: BusinessOperationStatus.Pending, + await invoke('check', attempt.req, code as number); + expect(await db.collection('businessOperations').countDocuments()).toBe(code === 0 ? 1 : 0); + }); + + test('/check does not allow recurrence startDate to bypass the discounted first amount', async () => { + const attempt = await payment(); + + attempt.req.body.Amount = '1'; + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + }, + }, }); + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); + }); - await pay(b); - const afterB = await workspace(); + test('unsigned identity, card-link and promo fields cannot override a signed promo', async () => { + const attempt = await payment(); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + isCardLinkOperation: true, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 1, + }, + }); + await invoke('check', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + type: BusinessOperationType.WorkspacePlanPurchase, + payload: { + workspaceId, + tariffPlanId: attempt.planId, + amount: 8000, + }, + }); + attempt.req.body.TransactionId = 1002; + attempt.req.body.Amount = '1'; + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + }); - expect(afterB).toMatchObject({ - tariffPlanId: b.planId, - subscriptionId: 'sub-B', - billingPaymentId: b.operationId, - billingPaymentDate: NEWER_PAYMENT_DATE, - lastChargeDate: NEWER_PAYMENT_DATE, - subscriptionIdsToCancel: [], + test('unsigned identity cannot override an ordinary signed purchase', async () => { + const attempt = await payment(false); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const userLookup = jest.spyOn(factories.usersFactory, 'findById'); + + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + isCardLinkOperation: true, + cloudPayments: { + recurrent: { + amount: 100, + interval: 'Month', + period: 1, + startDate: attempt.signedData.nextPaymentDate, + }, + }, + }); + await invoke('check', attempt.req); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId, + tariffPlanId: attempt.planId, + amount: 10000, + }, + }); + await invoke('pay', attempt.req); + expect(userLookup).toHaveBeenCalledWith(userId.toString()); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(await db.collection('businessOperations').findOne({ transactionId: '1001' })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', }); - await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); - const cancellationsBeforeRetry = callbacks.clientApi.cancelSubscription.mock.calls.length; + expectReceipt(100); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: workspaceId.toString(), + tariffPlanId: attempt.planId.toString(), + userId: userId.toString(), + }, + }); + expect(sendMessage).toHaveBeenCalledWith( + expect.stringContaining(`next payment date: ${attempt.signedData.nextPaymentDate}`), + TelegramBotURLs.Money + ); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); - for (let retry = 0; retry < 2; retry++) { - const res = await finish(a); + test('unsigned promo cannot discount an ordinary signed purchase', async () => { + const attempt = await payment(false); - expect(res.status).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ code: 0 }); - } - await expectFinished(a); - expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(2); - expect(sendNotification).toHaveBeenCalledTimes(2); - expect(callbacks.clientApi.cancelSubscription.mock.calls.slice(cancellationsBeforeRetry)) - .not.toContainEqual([ { Id: 'sub-B' } ]); - expect(await workspace()).toMatchObject({ - tariffPlanId: b.planId, - lastChargeDate: NEWER_PAYMENT_DATE, - billingPaymentId: b.operationId, - billingPaymentDate: NEWER_PAYMENT_DATE, - subscriptionId: 'sub-B', - subscriptionIdsToCancel: [], - billingPeriodEventsCount: 7, + attempt.req.body.Data = JSON.stringify({ + checksum: attempt.checksum, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 80, + }, }); + attempt.req.body.Amount = '80'; + await invoke('check', attempt.req, CheckCodes.WRONG_AMOUNT); + expect(await db.collection('businessOperations').countDocuments()).toBe(0); }); - test.each([ - ['promo', 'reject'], - ['promo', 'false'], - ['ordinary', 'reject'], - ['ordinary', 'false'], - ] as const)( - '%s cancellation %s must retain the pending subscription and retry without resetting usage', - async (kind, failure) => { - const a = await payment('A', PAYMENT_DATE, kind === 'promo'); - - if (failure === 'reject') { - callbacks.clientApi.cancelSubscription.mockRejectedValueOnce(new Error('subscription cancellation failed')); - } else { - callbacks.clientApi.cancelSubscription.mockResolvedValueOnce({ isSuccess: (): boolean => false }); - } - const failed = response(); - - await callbacks.pay(a.req, failed); - expect(failed.status).toHaveBeenCalledWith(500); - expect(await workspace()).toMatchObject({ - tariffPlanId: a.planId, - lastChargeDate: PAYMENT_DATE, - billingPaymentId: a.operationId, - billingPaymentDate: PAYMENT_DATE, - subscriptionId: 'sub-A', - subscriptionIdsToCancel: [ 'sub-old' ], + test('/pay redeems, follows the normal plan/subscription flow and reports the actual receipt amount', async () => { + const attempt = await payment(); + const operation = await pending(attempt); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const started = Date.now(); + + await invoke('pay', attempt.req); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledWith({ Id: 'sub-old' }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', + billingPeriodEventsCount: 0, + lastChargeDate: expect.any(Date), + }); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace!.lastChargeDate.getTime()).toBeGreaterThanOrEqual(started); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Confirmed }); + expect(await db.collection('promoCodeRedemptions').findOne({ promoCodeId: attempt.promoCodeId })).toMatchObject({ + userId, + workspaceId, + tariffPlanId: attempt.planId, + transactionId: '1001', + originalAmount: 100, + finalAmount: 80, + }); + expectReceipt(80); + expect(publish).toHaveBeenCalledWith('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId: workspaceId.toString(), + })); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: workspaceId.toString(), + tariffPlanId: attempt.planId.toString(), + userId: userId.toString(), + }, + }); + expect(sendMessage).toHaveBeenCalledWith(expect.stringMatching(/\[Billing \/ Pay\][\s\S]*amount: 80 RUB/), TelegramBotURLs.Money); + expect(HawkCatcher.send).not.toHaveBeenCalled(); + + /* + * Redemption is idempotent; callback side effects deliberately have no such guarantee. + */ + await invoke('pay', attempt.req); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + }); + + test.each(['before insert', 'after insert'] as const)( + '/pay retries a redemption persistence failure %s without fulfilling prematurely', async failure => { + const attempt = await payment(); + const operation = await pending(attempt); + const before = await db.collection('workspaces').findOne({ _id: workspaceId }); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const realRedeem = factories.promoCodeRedemptionsFactory.redeem.bind(factories.promoCodeRedemptionsFactory); + const redeem = jest.spyOn(factories.promoCodeRedemptionsFactory, 'redeem').mockImplementationOnce(async paymentData => { + if (failure === 'after insert') { + await realRedeem(paymentData); + } + throw new Error('Redemption database unavailable'); }); - expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ + const res = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + await callbacks.pay(attempt.req, res as unknown as Response); + expect(res.status.mock.calls).toEqual([ [ 500 ] ]); + expect(res.json.mock.calls).toEqual([ [ { code: 1 } ] ]); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Pending, }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toEqual(before); + expect(changePlan).not.toHaveBeenCalled(); expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); - expect(sendNotification).not.toHaveBeenCalled(); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); - await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); - - await pay(a); - expect(await workspace()).toMatchObject({ - tariffPlanId: a.planId, - lastChargeDate: PAYMENT_DATE, - billingPaymentId: a.operationId, - billingPaymentDate: PAYMENT_DATE, - subscriptionId: 'sub-A', - subscriptionIdsToCancel: [], - billingPeriodEventsCount: 7, + expect(sendNotification).not.toHaveBeenCalled(); + const persisted = await db.collection('promoCodeRedemptions').find({}) + .toArray(); + + expect(persisted).toHaveLength(failure === 'after insert' ? 1 : 0); + const errorMessage = '[Billing / Pay] Could not redeem promo code: Error: Redemption database unavailable'; + + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error(errorMessage), attempt.req.body); + expect(sendMessage).toHaveBeenCalledWith(expect.stringContaining(errorMessage), TelegramBotURLs.Money); + + await invoke('pay', attempt.req); + expect(redeem).toHaveBeenCalledTimes(2); + expect(redeem.mock.calls[1]).toEqual(redeem.mock.calls[0]); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ + status: BusinessOperationStatus.Confirmed, + }); + expect(changePlan).toHaveBeenCalledTimes(1); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + tariffPlanId: attempt.planId, + subscriptionId: 'sub-new', + billingPeriodEventsCount: 0, }); - expect(callbacks.clientApi.cancelSubscription.mock.calls).toEqual([ - [ { Id: 'sub-old' } ], - [ { Id: 'sub-old' } ], - ]); + expectReceipt(80); expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(1); - expect(sendNotification).toHaveBeenCalledTimes(1); expect(publish).toHaveBeenCalledTimes(1); - expect(await db.collection('businessOperations').findOne({ _id: a.operationId })).toMatchObject({ - status: BusinessOperationStatus.Confirmed, + expect(sendNotification).toHaveBeenCalledTimes(1); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expect(await db.collection('promoCodeRedemptions').findOne({ transactionId: '1001' })).toMatchObject({ + promoCodeId: attempt.promoCodeId, + userId, + workspaceId, + tariffPlanId: attempt.planId, + originalAmount: 100, + finalAmount: 80, }); - if (kind === 'promo') { - await expectFinished(a); + if (failure === 'after insert') { + expect(await db.collection('promoCodeRedemptions').find({}) + .toArray()).toEqual(persisted); } } ); - test.each([SubscriptionStatus.CANCELLED, SubscriptionStatus.REJECTED])( - 'stale /recurrent %s must not clear a newer active subscription', - async status => { - const snapshot = await factories.workspacesFactory.findBySubscriptionId('sub-old'); + test.each([ + ['amount', { 'payload.amount': 7900 } ], + ['currency', { 'payload.currency': 'USD' } ], + ['workspace', { 'payload.workspaceId': new ObjectId() } ], + ['payer', { 'payload.userId': new ObjectId() } ], + ['plan', { 'payload.tariffPlanId': new ObjectId() } ], + ['operation type', { type: BusinessOperationType.CardLinkCharge } ], + ])('/pay rejects a mismatched operation %s before redeeming or changing the plan', async (_name, changes) => { + const attempt = await payment(); + const operation = await pending(attempt); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + + await db.collection('businessOperations').updateOne({ _id: operation._id }, { $set: changes }); + await invoke('pay', attempt.req); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Pending }); + expect(changePlan).not.toHaveBeenCalled(); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(HawkCatcher.send).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Invalid promo payment') }), attempt.req.body); + }); + + test('/fail rejects the operation without consumption or plan changes and retains monitoring', async () => { + const attempt = await payment(); + const operation = await pending(attempt); + const before = await db.collection('workspaces').findOne({ _id: workspaceId }); + const redeem = jest.spyOn(factories.promoCodeRedemptionsFactory, 'redeem'); + + await invoke('fail', attempt.req); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Rejected }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toEqual(before); + expect(redeem).not.toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(callbacks.receiptApi.createReceipt).not.toHaveBeenCalled(); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ _id: userId }), { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + workspaceId: workspaceId.toString(), + reason: ReasonCodesTranscript[ReasonCode.DO_NOT_HONOR], + }, + }); + expect(sendMessage).toHaveBeenCalledWith(expect.stringMatching(/\[Billing \/ Fail\][\s\S]*amount: 80 RUB[\s\S]*transaction id: 1001/), TelegramBotURLs.Money); + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error('[Billing / Fail] Transaction failed'), attempt.req.body); + }); + + test.each(['pay', 'fail'] as const)('expired promo checksum is rejected by /check but accepted by /%s for an existing operation', async method => { + const attempt = await payment(); + const operation = await pending(attempt); + + attempt.req.body.Data = JSON.stringify({ checksum: jwt.sign(attempt.signedData, SECRET, { expiresIn: -1 }) }); + await invoke('check', attempt.req, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(await db.collection('businessOperations').countDocuments()).toBe(1); + await invoke(method, attempt.req); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ + status: method === 'pay' ? BusinessOperationStatus.Confirmed : BusinessOperationStatus.Rejected, + }); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(method === 'pay' ? 1 : 0); + }); + + test('ordinary cancellation Success:false still sends the receipt and unblocks the workspace', async () => { + const attempt = await payment(false); + const operation = await pending(attempt); + + callbacks.clientApi.cancelSubscription.mockResolvedValueOnce({ + Success: false, + isSuccess: (): boolean => false, + }); + await invoke('pay', attempt.req); + expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledWith({ Id: 'sub-old' }); + expect(await db.collection('businessOperations').findOne({ _id: operation._id })).toMatchObject({ status: BusinessOperationStatus.Confirmed }); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + subscriptionId: 'sub-new', + tariffPlanId: attempt.planId, + }); + expectReceipt(100); + expect(publish).toHaveBeenCalled(); + expect(sendNotification).toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + }); - expect(snapshot).not.toBeNull(); - const b = await payment('B', NEWER_PAYMENT_DATE); + test('ordinary payment with older dtCreated still calls changePlan and resets the billing period', async () => { + const attempt = await payment(false); - await pay(b); - const afterB = await workspace(); - const lookup = jest.spyOn(factories.workspacesFactory, 'findBySubscriptionId').mockResolvedValue(snapshot); + await pending(attempt, new Date(PAYMENT_DATE.getTime() - 86400000)); + const changePlan = jest.spyOn(WorkspaceModel.prototype, 'changePlan'); + const started = Date.now(); + + await invoke('pay', attempt.req); + expect(changePlan).toHaveBeenCalledWith(attempt.planId); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); + + expect(workspace).toMatchObject({ + tariffPlanId: attempt.planId, + billingPeriodEventsCount: 0, + }); + expect(workspace!.lastChargeDate.getTime()).toBeGreaterThanOrEqual(started); + expectReceipt(100); + }); + + test('a later recurring charge without widget data uses the ordinary full-price flow', async () => { + const attempt = await payment(false); + + await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { tariffPlanId: attempt.planId } }); + delete attempt.req.body.Data; + attempt.req.body.SubscriptionId = 'sub-old'; + attempt.req.body.AccountId = userId.toString(); + await invoke('check', attempt.req); + await invoke('pay', attempt.req); + expectReceipt(100); + expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalled(); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(0); + expect(await db.collection('workspaces').findOne({ _id: workspaceId })).toMatchObject({ + billingPeriodEventsCount: 0, + subscriptionId: 'sub-old', + }); + }); + + test.each([SubscriptionStatus.CANCELLED, SubscriptionStatus.REJECTED, SubscriptionStatus.ACTIVE])( + '/recurrent %s uses the normal workspace model', async status => { + const lookup = jest.spyOn(factories.workspacesFactory, 'findBySubscriptionId'); + const setSubscription = jest.spyOn(WorkspaceModel.prototype, 'setSubscriptionId'); const req = { - path: '/recurrent', body: { Id: 'sub-old', Status: status, }, context: { factories }, } as unknown as Request; - const res = response(); - - try { - await callbacks.recurrent(req, res); - expect(lookup).toHaveBeenCalledWith('sub-old'); - expect(res.status).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ code: 0 }); - expect(await workspace()).toEqual(afterB); - expect(await workspace()).toMatchObject({ subscriptionId: 'sub-B' }); - } finally { - lookup.mockRestore(); - } - } - ); - test.each([ - ['promo', 'promo'], - ['promo', 'ordinary'], - ['ordinary', 'promo'], - ] as const)( - '%s A / %s B: newer B must retain its plan and subscription when A resumes after external cancellation', - async (kindA, kindB) => { - const a = await payment('A', PAYMENT_DATE, kindA === 'promo'); - const b = await payment('B', NEWER_PAYMENT_DATE, kindB === 'promo'); - const cancellationEntered = semaphore(); - const resumeCancellation = semaphore(); - let paused = false; - - callbacks.clientApi.cancelSubscription.mockImplementation(async ({ Id }: { Id: string }) => { - if (Id === 'sub-old' && !paused) { - paused = true; - cancellationEntered.release(); - await resumeCancellation.promise; - } + await invoke('recurrent', req); + expect(lookup).toHaveBeenCalledWith('sub-old'); + const workspace = await db.collection('workspaces').findOne({ _id: workspaceId }); - return { isSuccess: (): boolean => true }; - }); - const aResponse = response(); - const runningA = callbacks.pay(a.req, aResponse); - - try { - await Promise.race([ - cancellationEntered.promise, - runningA.then(() => { - throw new Error('Payment A finished without reaching the cancellation barrier'); - }), - ]); - expect(await workspace()).toMatchObject({ - tariffPlanId: a.planId, - lastChargeDate: PAYMENT_DATE, - subscriptionId: 'sub-A', - subscriptionIdsToCancel: [ 'sub-old' ], - }); - await pay(b); - expect(await workspace()).toMatchObject({ - tariffPlanId: b.planId, - lastChargeDate: NEWER_PAYMENT_DATE, - billingPaymentId: b.operationId, - billingPaymentDate: NEWER_PAYMENT_DATE, - subscriptionId: 'sub-B', - subscriptionIdsToCancel: [], - }); - } finally { - resumeCancellation.release(); - await runningA; - } - expect(aResponse.status).not.toHaveBeenCalled(); - expect(aResponse.json).toHaveBeenCalledWith({ code: 0 }); - const afterRace = await workspace(); - - await db.collection('workspaces').updateOne({ _id: workspaceId }, { $set: { billingPeriodEventsCount: 7 } }); - const cancellations = callbacks.clientApi.cancelSubscription.mock.calls.length; - const receipts = callbacks.receiptApi.createReceipt.mock.calls.length; - const notifications = jest.mocked(sendNotification).mock.calls.length; - const publications = jest.mocked(publish).mock.calls.length; - - if (kindA === 'promo') { - await pay(a); - await expectFinished(a); - } - if (kindB === 'promo') { - await pay(b); - await expectFinished(b); - } - expect(callbacks.clientApi.cancelSubscription).toHaveBeenCalledTimes(cancellations); - expect(callbacks.receiptApi.createReceipt).toHaveBeenCalledTimes(receipts); - expect(sendNotification).toHaveBeenCalledTimes(notifications); - expect(publish).toHaveBeenCalledTimes(publications); - if (kindA === 'ordinary') { - await pay(a); + if (status === SubscriptionStatus.ACTIVE) { + expect(setSubscription).not.toHaveBeenCalled(); + expect(workspace!.subscriptionId).toBe('sub-old'); + } else { + expect(setSubscription).toHaveBeenCalledWith(null); + expect(workspace!.subscriptionId).toBeFalsy(); } - expect(callbacks.clientApi.cancelSubscription).not.toHaveBeenCalledWith({ Id: 'sub-B' }); - expect(await workspace()).toEqual({ - ...afterRace, - billingPeriodEventsCount: 7, - }); - expect(afterRace).toMatchObject({ - tariffPlanId: b.planId, - lastChargeDate: NEWER_PAYMENT_DATE, - billingPaymentId: b.operationId, - billingPaymentDate: NEWER_PAYMENT_DATE, - subscriptionId: 'sub-B', - subscriptionIdsToCancel: [], - }); + expect(HawkCatcher.send).toHaveBeenCalledWith(new Error(`[Billing / Recurrent] New recurrent event with ${status} status`), req.body); } ); }); diff --git a/test/services/promoCodes/checksum.test.ts b/test/services/promoCodes/checksum.test.ts new file mode 100644 index 00000000..598380f6 --- /dev/null +++ b/test/services/promoCodes/checksum.test.ts @@ -0,0 +1,97 @@ +import jwt from 'jsonwebtoken'; +import { ObjectId } from 'mongodb'; +import checksumService from '../../../src/utils/checksumService'; + +describe('Promo payment checksums', () => { + const secret = 'promo-checksum-tests-only'; + const previousSecret = process.env.JWT_SECRET_BILLING_CHECKSUM; + const purchase = { + workspaceId: new ObjectId().toString(), + userId: new ObjectId().toString(), + tariffPlanId: new ObjectId().toString(), + shouldSaveCard: false, + nextPaymentDate: '2026-11-13T12:00:00.000Z', + }; + const promoPurchase = { + ...purchase, + promo: { + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + finalAmount: 80, + }, + }; + const cardLink = { + workspaceId: purchase.workspaceId, + userId: purchase.userId, + isCardLinkOperation: true, + nextPaymentDate: purchase.nextPaymentDate, + }; + + beforeAll(() => { + process.env.JWT_SECRET_BILLING_CHECKSUM = secret; + }); + + afterAll(() => { + if (previousSecret === undefined) { + delete process.env.JWT_SECRET_BILLING_CHECKSUM; + } else { + process.env.JWT_SECRET_BILLING_CHECKSUM = previousSecret; + } + }); + + test.each([purchase, promoPurchase, cardLink])('round-trips signed payment data without JWT metadata', async data => { + const checksum = await checksumService.generateChecksum(data); + const payload = jwt.verify(checksum, secret) as jwt.JwtPayload; + + expect(payload.exp! - payload.iat!).toBe(30 * 60); + expect(checksumService.parseAndVerifyChecksum(checksum)).toEqual(data); + }); + + test('expired promo is rejected for /check by default and allowed only for completion', () => { + const checksum = jwt.sign(promoPurchase, secret, { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum)).toThrow(jwt.TokenExpiredError); + expect(() => checksumService.parseAndVerifyChecksum(checksum, false)).toThrow(jwt.TokenExpiredError); + expect(checksumService.parseAndVerifyChecksum(checksum, true)).toEqual(promoPurchase); + }); + + test.each([purchase, cardLink, { + ...purchase, + promo: null, + } ])('completion does not accept an expired non-promo checksum', data => { + const checksum = jwt.sign(data, secret, { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum)).toThrow(jwt.TokenExpiredError); + expect(() => checksumService.parseAndVerifyChecksum(checksum, true)).toThrow(jwt.TokenExpiredError); + }); + + test.each([false, true])('nested price tampering fails signature verification (completion: %s)', async completion => { + const checksum = await checksumService.generateChecksum(promoPurchase); + const [header, encoded, signature] = checksum.split('.'); + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString()); + + payload.promo.finalAmount = 1; + const tampered = [header, Buffer.from(JSON.stringify(payload)).toString('base64url'), signature].join('.'); + + expect(() => checksumService.parseAndVerifyChecksum(tampered, completion)).toThrow('invalid signature'); + }); + + test('completion still verifies the signature of an expired promo', () => { + const checksum = jwt.sign(promoPurchase, 'wrong-secret', { expiresIn: -1 }); + + expect(() => checksumService.parseAndVerifyChecksum(checksum, true)).toThrow('invalid signature'); + }); + + test('legacy top-level promo fields are not returned as signed payment data', () => { + const checksum = jwt.sign({ + ...purchase, + promoPaymentId: new ObjectId().toString(), + promoCodeId: new ObjectId().toString(), + originalAmount: 100, + paymentAmount: 80, + discountPercent: 20, + }, secret); + + expect(checksumService.parseAndVerifyChecksum(checksum)).toEqual(purchase); + }); +}); diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts index 625c7718..791d4567 100644 --- a/test/services/promoCodes/index.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -1,46 +1,31 @@ -import type { Db } from 'mongodb'; import { ObjectId } from 'mongodb'; import { - calculateDiscountedAmount, - findActivePromoCode, + applyDiscount, + ensureActivePromoCode, getMaxUses, normalizePromoCode, - PromoCode + PromoCode, + PromoCodeDiscount } from '../../../src/billing/promoCodes'; describe('Promo code normalization', () => { test('should trim whitespace and normalize case', () => { - const code = ' sale20 '; - - const result = normalizePromoCode(code); - - expect(result).toBe('SALE20'); + expect(normalizePromoCode(' sale20 ')).toBe('SALE20'); }); - test.each(['', ' '])('should reject an empty code: %j', code => { - /** - * Normalize the empty code to exercise validation. - * - * @returns The normalized code if validation succeeds. - */ - const action = (): string => normalizePromoCode(code); - - expect(action).toThrow('PROMO_CODE_INVALID'); + test.each(['', ' ', null, undefined, 20])('should reject invalid input: %j', code => { + expect(() => normalizePromoCode(code as string)).toThrow('PROMO_CODE_INVALID'); }); }); describe('Promo code usage limit', () => { test.each([0, 1, 10])('should preserve numeric limit %j', value => { - const result = getMaxUses(value); - - expect(result).toBe(value); + expect(getMaxUses(value)).toBe(value); }); test.each([undefined, null, -1, '10', '0', 'invalid', NaN, Infinity, false])( 'should treat %j as unlimited without converting strings', value => { - const result = getMaxUses(value); - - expect(result).toBeUndefined(); + expect(getMaxUses(value)).toBeUndefined(); } ); }); @@ -54,161 +39,105 @@ describe('Discounted payment amount', () => { [100, 99, 1], [200, 99, 2], [100, 1, 99], + [100, 71, 29], ])('should discount %j RUB by %j percent to %j RUB', (amount, percent, expected) => { - const result = calculateDiscountedAmount(amount, percent); - - expect(result).toBe(expected); + expect(applyDiscount({ + type: 'percent', + value: percent, + }, amount)).toBe(expected); }); - test.each([0, -1, NaN, Infinity])('should reject invalid amount %j', amount => { - const discountPercent = 20; - - /** - * Calculate a discount for an invalid amount to exercise validation. - * - * @returns The discounted amount if validation succeeds. - */ - const action = (): number => calculateDiscountedAmount(amount, discountPercent); - - expect(action).toThrow('PROMO_CODE_INVALID_AMOUNT'); + test.each([ + [100, 20, 80], + [100, 19.5, 81], + [100.5, 20, 81], + [100, 100, 1], + [100, 200, 1], + [0.5, 1, 1], + ])('should discount %j RUB by a fixed %j RUB to %j RUB', (amount, value, expected) => { + expect(applyDiscount({ + type: 'fixed', + value, + }, amount)).toBe(expected); }); - test.each([0, -1, 100, NaN, Infinity])('should reject invalid discount %j', percent => { - const originalAmount = 100; - - /** - * Calculate an amount with an invalid discount to exercise validation. - * - * @returns The discounted amount if validation succeeds. - */ - const action = (): number => calculateDiscountedAmount(originalAmount, percent); - - expect(action).toThrow('PROMO_CODE_INVALID'); - }); + test.each([0, -1, NaN, Infinity])('should reject invalid amount %j', amount => { + expect(() => applyDiscount({ + type: 'percent', + value: 20, + }, amount)).toThrow('PROMO_CODE_INVALID_AMOUNT'); + }); + + test.each([0, -1, 100, 29.1, NaN, Infinity])('should reject invalid percent %j', value => { + expect(() => applyDiscount({ + type: 'percent', + value, + }, 100)).toThrow('PROMO_CODE_INVALID'); + }); + + test.each([0, -1, NaN, Infinity])('should reject invalid fixed discount %j', value => { + expect(() => applyDiscount({ + type: 'fixed', + value, + }, 100)).toThrow('PROMO_CODE_INVALID'); + }); + + test.each([undefined, null, {}, { + type: 'other', + value: 20, + }, { + type: 'percent', + value: '20', + } ])( + 'should reject malformed discount %j', discount => { + expect(() => applyDiscount(discount as PromoCodeDiscount, 100)).toThrow('PROMO_CODE_INVALID'); + } + ); }); -describe('Active promo code lookup', () => { - const findOne = jest.fn(); - const collection = jest.fn().mockReturnValue({ findOne }); - const db = { collection } as unknown as Db; +describe('Active promo code validation', () => { let promoCode: PromoCode; let planId: ObjectId; beforeEach(() => { - jest.clearAllMocks(); planId = new ObjectId(); promoCode = { _id: new ObjectId(), value: 'SALE20', - discountPercent: 20, + discount: { + type: 'percent', + value: 20, + }, }; - findOne.mockResolvedValue(promoCode); }); - test('should allow any plan when planIds is absent', async () => { - const code = ' sale20 '; - - const result = await findActivePromoCode(db, code, planId); - - expect(result).toEqual(promoCode); - expect(collection).toHaveBeenCalledWith('promoCodes'); - expect(findOne).toHaveBeenCalledWith({ value: 'SALE20' }); + test('should allow any plan when planIds is absent', () => { + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); }); - test('should reject a code that does not exist', async () => { - findOne.mockResolvedValue(null); - - const result = findActivePromoCode(db, 'UNKNOWN', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + test('should reject a code that does not exist', () => { + expect(() => ensureActivePromoCode(null, planId)).toThrow('PROMO_CODE_INVALID'); }); - test('should reject a disabled code', async () => { + test('should reject a disabled code', () => { promoCode.isDisabled = true; - - const result = findActivePromoCode(db, 'SALE20', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + expect(() => ensureActivePromoCode(promoCode, planId)).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.each([undefined, false, null, 0, '', 'true'])('should allow isDisabled %j', isDisabled => { + promoCode.isDisabled = isDisabled as boolean; + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); }); - test('should allow a plan explicitly listed in planIds', async () => { + test('should allow a plan explicitly listed in planIds', () => { promoCode.planIds = [new ObjectId(), planId]; - - const result = await findActivePromoCode(db, 'SALE20', planId); - - expect(result).toEqual(promoCode); + expect(() => ensureActivePromoCode(promoCode, planId)).not.toThrow(); }); - test('should reject a plan not listed in planIds', async () => { - promoCode.planIds = [ new ObjectId() ]; - - const result = findActivePromoCode(db, 'SALE20', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); - }); - - test('should allow no plans when planIds is empty', async () => { - promoCode.planIds = []; - - const result = findActivePromoCode(db, 'SALE20', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); - }); - - test.each([ - { planIds: null }, - { planIds: 'all' }, - { planIds: [ null ] }, - { planIds: [ 'not-a-plan-id' ] }, - ])('should reject malformed planIds %j', async ({ planIds }) => { - findOne.mockResolvedValue({ - ...promoCode, - planIds, - }); - - const result = findActivePromoCode(db, 'SALE20', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); - }); - - test('should reject an invalid discount from the database', async () => { - promoCode.discountPercent = 100; - - const result = findActivePromoCode(db, 'SALE20', planId); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); - }); - - test('should preserve zero for later enforcement during reservation', async () => { - promoCode.maxUses = 0; - - const result = await findActivePromoCode(db, 'SALE20', planId); - - expect(result).toMatchObject({ maxUses: 0 }); - }); - - test('should treat a string limit from the database as unlimited', async () => { - const storedPromoCode = { - ...promoCode, - maxUses: '10', - }; - - findOne.mockResolvedValue(storedPromoCode); - - const result = await findActivePromoCode(db, 'SALE20', planId); - - expect(result.maxUses).toBeUndefined(); - expect(storedPromoCode.maxUses).toBe('10'); - }); + test.each([ [], [ new ObjectId() ], null, 'all', [ null ], [ 'not-a-plan-id' ] ])( + 'should reject ineligible or malformed planIds %j', planIds => { + promoCode.planIds = planIds as ObjectId[]; + expect(() => ensureActivePromoCode(promoCode, planId)).toThrow('PROMO_CODE_INVALID'); + } + ); }); diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index 531a6fa5..e49052d4 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -1,10 +1,13 @@ import type { Db } from 'mongodb'; -import { MongoServerError, ObjectId } from 'mongodb'; -import { preparePromoCode, recordPromoCodeUsage, validatePromoPayment } from '../../../src/billing/promoCodes/redemptions'; +import { MongoClient, MongoServerError, ObjectId } from 'mongodb'; +import { validatePromoPayment } from '../../../src/billing/promoCodes/redemptions'; +import PromoCodesFactory from '../../../src/models/promoCodesFactory'; +import PromoCodeRedemptionsFactory from '../../../src/models/promoCodeRedemptionsFactory'; import type { PromoCodeInput, PromoPayment, PromoCodeRedemption } from '../../../src/billing/promoCodes/redemptions'; import type { PromoCode } from '../../../src/billing/promoCodes'; +import migration from '../../../migrations/20261013000000-create-promo-code-collections'; -describe('Promo code usage', () => { +describe('Promo code factories and successful usage', () => { const findPromoCode = jest.fn(); const findRedemption = jest.fn(); const countDocuments = jest.fn(); @@ -15,13 +18,18 @@ describe('Promo code usage', () => { let input: PromoCodeInput; let payment: PromoPayment; let existingUse: PromoCodeRedemption; + let promoCodesFactory: PromoCodesFactory; + let redemptionsFactory: PromoCodeRedemptionsFactory; beforeEach(() => { jest.resetAllMocks(); promoCode = { _id: new ObjectId(), value: 'SALE20', - discountPercent: 20, + discount: { + type: 'percent', + value: 20, + }, }; input = { promoCode: ' sale20 ', @@ -32,15 +40,13 @@ describe('Promo code usage', () => { }; 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, + finalAmount: 80, transactionId: 'payment-123', - amount: 80, + paidAmount: 80, currency: 'RUB', }; existingUse = { @@ -52,7 +58,6 @@ describe('Promo code usage', () => { transactionId: payment.transactionId, originalAmount: 100, finalAmount: 80, - discountPercent: 20, }; collection.mockImplementation((name: string) => { if (name === 'promoCodes') { @@ -69,50 +74,103 @@ describe('Promo code usage', () => { findRedemption.mockResolvedValue(null); countDocuments.mockResolvedValue(0); insertOne.mockResolvedValue({ acknowledged: true }); + redemptionsFactory = new PromoCodeRedemptionsFactory(db); + promoCodesFactory = new PromoCodesFactory(db, redemptionsFactory); }); - test('should prepare a discounted quote without creating a usage record', async () => { - const expectedAmount = 80; - - const result = await preparePromoCode(db, input); - - expect(result).toEqual({ + test('should calculate a quote with one normalized lookup and no usage writes', async () => { + expect(await promoCodesFactory.calculatePromoQuote(input)).toEqual({ promoCodeId: promoCode._id, originalAmount: 100, - finalAmount: expectedAmount, - discountPercent: 20, + finalAmount: 80, + }); + expect(collection).toHaveBeenCalledWith('promoCodes'); + expect(collection).toHaveBeenCalledWith('promoCodeRedemptions'); + expect(findPromoCode).toHaveBeenCalledTimes(1); + expect(findPromoCode).toHaveBeenCalledWith({ value: 'SALE20' }); + expect(findRedemption).toHaveBeenCalledWith({ + promoCodeId: promoCode._id, + $or: [ { userId: input.userId }, { workspaceId: input.workspaceId } ], }); expect(insertOne).not.toHaveBeenCalled(); }); - test('should allow repeated preparation while no successful use exists', async () => { - const first = await preparePromoCode(db, input); + test('should calculate fixed discounts', async () => { + promoCode.discount = { + type: 'fixed', + value: 25.5, + }; + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 75 }); + }); + + test('should read legacy discountPercent without modifying the document', async () => { + const legacy = { + ...promoCode, + discount: undefined, + discountPercent: 20, + }; + + findPromoCode.mockResolvedValue(legacy); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); + expect(legacy.discount).toBeUndefined(); + }); + + test('should prefer the new discount over legacy discountPercent', async () => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discountPercent: 50, + }); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); + }); + + test.each([null, { + type: 'percent', + value: 29.1, + }, { + type: 'fixed', + value: -1, + } ])( + 'should not fall back to a legacy value for invalid new discount %j', async discount => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discount, + discountPercent: 20, + }); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + } + ); + + test.each([undefined, '20', 29.1])('should reject missing or invalid legacy discount %j', async discountPercent => { + findPromoCode.mockResolvedValue({ + ...promoCode, + discount: undefined, + discountPercent, + }); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + }); - const second = await preparePromoCode(db, input); + test('should allow repeated quotes while no successful use exists', async () => { + const first = await promoCodesFactory.calculatePromoQuote(input); - expect(second).toEqual(first); + expect(await promoCodesFactory.calculatePromoQuote(input)).toEqual(first); expect(insertOne).not.toHaveBeenCalled(); }); - test.each(['userId', 'workspaceId'] as const)('should reject successful reuse by the same %s', async field => { + test.each(['userId', 'workspaceId'] as const)('should reject reuse by the same %s', async field => { const otherField = field === 'userId' ? 'workspaceId' : 'userId'; existingUse[otherField] = new ObjectId(); findRedemption.mockResolvedValue(existingUse); - - const result = preparePromoCode(db, input); - - await expect(result).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); expect(insertOne).not.toHaveBeenCalled(); }); test.each([0, 1])('should reject an exhausted limit of %j', async maxUses => { promoCode.maxUses = maxUses; countDocuments.mockResolvedValue(maxUses); - - const result = preparePromoCode(db, input); - - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_INVALID'); expect(countDocuments).toHaveBeenCalledWith({ promoCodeId: promoCode._id }); }); @@ -121,50 +179,96 @@ describe('Promo code usage', () => { ...promoCode, maxUses, }); - - const result = await preparePromoCode(db, input); - - expect(result.finalAmount).toBe(80); + expect(await promoCodesFactory.calculatePromoQuote(input)).toMatchObject({ finalAmount: 80 }); expect(countDocuments).not.toHaveBeenCalled(); }); - test('should reject a plan outside the allowed list', async () => { - promoCode.planIds = [ new ObjectId() ]; + test.each(['missing', 'disabled', 'wrong-plan'])('should reject %s codes for quotes and checks', async reason => { + if (reason === 'missing') { + findPromoCode.mockResolvedValue(null); + } else if (reason === 'disabled') { + promoCode.isDisabled = true; + } else { + promoCode.planIds = [ new ObjectId() ]; + } - const result = preparePromoCode(db, input); + await expect(promoCodesFactory.calculatePromoQuote(input)).rejects.toThrow('PROMO_CODE_INVALID'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_INVALID'); + expect(findRedemption).not.toHaveBeenCalled(); + }); - await expect(result).rejects.toThrow('PROMO_CODE_INVALID'); + test('should check payment availability with one ID lookup and without recomputing discount', async () => { + promoCode.discount = { + type: 'fixed', + value: 90, + }; + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + expect(findPromoCode).toHaveBeenCalledTimes(1); + expect(findPromoCode).toHaveBeenCalledWith({ _id: promoCode._id }); expect(insertOne).not.toHaveBeenCalled(); }); - test('should record only successful usage without status or expiry fields', async () => { - const callback = payment; + test('should allow the same successful payment retry even at the usage limit', async () => { + promoCode.maxUses = 1; + findRedemption.mockResolvedValue(existingUse); + countDocuments.mockResolvedValue(1); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + expect(countDocuments).not.toHaveBeenCalled(); + }); + + test('should reject a different transaction using the same identities', async () => { + existingUse.transactionId = 'another-payment'; + findRedemption.mockResolvedValue(existingUse); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_ALREADY_USED'); + }); - await recordPromoCodeUsage(db, callback); + test('maxUses is soft: parallel checked payments may exceed it and both successes must be recorded', async () => { + promoCode.maxUses = 1; + const otherPayment = { + ...payment, + userId: new ObjectId().toString(), + workspaceId: new ObjectId().toString(), + transactionId: 'parallel-payment', + }; + + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + countDocuments.mockResolvedValue(1); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(insertOne).toHaveBeenCalledTimes(2); + expect(countDocuments).toHaveBeenCalledTimes(2); + expect(findPromoCode).toHaveBeenCalledTimes(2); + }); + test('should record only successful usage without status, expiry, discount or attempt fields', async () => { + await redemptionsFactory.redeem(payment); expect(insertOne).toHaveBeenCalledWith({ ...existingUse, _id: expect.any(ObjectId), }); - const stored = insertOne.mock.calls[0][0]; - - expect(stored).not.toHaveProperty('status'); - expect(stored).not.toHaveProperty('expiresAt'); + expect(findPromoCode).not.toHaveBeenCalled(); + expect(countDocuments).not.toHaveBeenCalled(); }); - test('should not create another row when the same successful callback is repeated', async () => { + test('should tolerate the unique-index conflict on a successful callback retry', async () => { findRedemption.mockResolvedValue(existingUse); insertOne.mockRejectedValue(new MongoServerError({ code: 11000, message: 'Duplicate key', })); - - const result = recordPromoCodeUsage(db, payment); - - await expect(result).resolves.toBeUndefined(); + await expect(redemptionsFactory.redeem(payment)).resolves.toBeUndefined(); + expect(findRedemption).toHaveBeenCalledWith({ + promoCodeId: promoCode._id, + $or: [ { userId: input.userId }, { workspaceId: input.workspaceId } ], + }); }); - test('should not lose the paid plan when a parallel successful payment uses the same code', async () => { + test.each(['userId', 'workspaceId'] as const)('should not block fulfillment of a parallel success sharing %s', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + + existingUse[otherField] = new ObjectId(); existingUse.transactionId = 'another-payment'; findRedemption.mockResolvedValue(existingUse); insertOne.mockRejectedValue(new MongoServerError({ @@ -174,36 +278,182 @@ describe('Promo code usage', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); try { - const result = recordPromoCodeUsage(db, payment); - - await expect(result).resolves.toBeUndefined(); - expect(warn).toHaveBeenCalled(); + await expect(redemptionsFactory.redeem(payment)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith('Promo code was used by a parallel successful payment', payment.transactionId); } finally { warn.mockRestore(); } }); - test('should propagate a database failure so the callback can be retried', async () => { - const error = new Error('Database unavailable'); - + test.each([ + new Error('Database unavailable'), + new MongoServerError({ + code: 11000, + message: 'Unrelated duplicate key', + }), + new MongoServerError({ + code: 123, + message: 'Other MongoDB failure', + }), + ])('should propagate a database failure without a matching usage: %s', async error => { insertOne.mockRejectedValue(error); + await expect(redemptionsFactory.redeem(payment)).rejects.toBe(error); + }); + + test('should accept the signed snapshot without needing discount data or an attempt ID', () => { + payment.finalAmount = 37; + payment.paidAmount = 37; + expect(() => validatePromoPayment(payment)).not.toThrow(); + }); - const result = recordPromoCodeUsage(db, payment); + test('should allow the rounded one-ruble floor for a sub-ruble original price', () => { + payment.originalAmount = 0.5; + payment.finalAmount = 1; + payment.paidAmount = 1; + expect(() => validatePromoPayment(payment)).not.toThrow(); + }); - await expect(result).rejects.toBe(error); + test.each([1, 81, NaN, Infinity, '80'])('should reject changed paid amount %j before DB access', async paidAmount => { + payment.paidAmount = paidAmount as number; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); + await expect(redemptionsFactory.redeem(payment)).rejects.toThrow('PROMO_CODE_WRONG_AMOUNT'); + expect(findPromoCode).not.toHaveBeenCalled(); + expect(insertOne).not.toHaveBeenCalled(); }); - test.each([1, 81, NaN])('should reject a callback with a changed amount %j', amount => { - payment.amount = amount; + test.each([0, -1, 80.5, 101, NaN, Infinity])('should reject invalid signed final amount %j even if paid', finalAmount => { + payment.finalAmount = finalAmount; + payment.paidAmount = finalAmount; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + }); - /** - * Validate the changed callback amount to exercise payment validation. - * - * @returns Nothing if validation succeeds. - */ - const action = (): void => validatePromoPayment(payment); + test.each([0, -1, NaN, Infinity, '100'])('should reject invalid original amount %j', originalAmount => { + payment.originalAmount = originalAmount as number; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_WRONG_AMOUNT'); + }); - expect(action).toThrow('PROMO_CODE_WRONG_AMOUNT'); - expect(insertOne).not.toHaveBeenCalled(); + test.each(['promoCodeId', 'userId', 'workspaceId', 'tariffPlanId'] as const)('should reject invalid %s', field => { + payment[field] = 'not-an-object-id'; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); + + test.each(['', ' ', undefined, 123])('should reject invalid transaction ID %j', transactionId => { + payment.transactionId = transactionId as string; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); + + test.each(['USD', 'rub', '', undefined])('should reject currency %j', currency => { + payment.currency = currency as string; + expect(() => validatePromoPayment(payment)).toThrow('PROMO_CODE_INVALID_PAYMENT'); + }); +}); + +describe('Promo factories with MongoDB unique indexes', () => { + let client: MongoClient; + let db: Db; + let promoCodesFactory: PromoCodesFactory; + let redemptionsFactory: PromoCodeRedemptionsFactory; + let payment: PromoPayment; + + beforeAll(async () => { + const globals = global as unknown as { __MONGO_URI__?: string }; + const uri = process.env.MONGO_URL || globals.__MONGO_URI__; + + if (!uri) { + throw new Error('Jest MongoDB test URI is not configured'); + } + + client = new MongoClient(uri); + await client.connect(); + db = client.db('promo-factory-tests-' + new ObjectId().toHexString()); + await migration.up(db); + redemptionsFactory = new PromoCodeRedemptionsFactory(db); + promoCodesFactory = new PromoCodesFactory(db, redemptionsFactory); + }); + + beforeEach(async () => { + await db.collection('promoCodes').deleteMany({}); + await db.collection('promoCodeRedemptions').deleteMany({}); + const promoCodeId = new ObjectId(); + + await db.collection('promoCodes').insertOne({ + _id: promoCodeId, + value: 'SALE20', + discount: { + type: 'percent', + value: 20, + }, + maxUses: 1, + }); + payment = { + promoCodeId: promoCodeId.toHexString(), + originalAmount: 100, + finalAmount: 80, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + tariffPlanId: new ObjectId().toHexString(), + transactionId: 'successful-payment', + paidAmount: 80, + currency: 'RUB', + }; + }); + + afterAll(async () => { + try { + await db?.dropDatabase(); + } finally { + await client?.close(); + } + }); + + test('concurrent callback retries should persist exactly one usage', async () => { + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(payment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + await expect(promoCodesFactory.ensurePaymentAvailable(payment)).resolves.toBeUndefined(); + }); + + test.each(['userId', 'workspaceId'] as const)('parallel successes sharing %s should keep one usage without rejecting fulfillment', async field => { + const otherField = field === 'userId' ? 'workspaceId' : 'userId'; + const otherPayment = { + ...payment, + [otherField]: new ObjectId().toHexString(), + transactionId: 'parallel-payment', + }; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + test('parallel successes with distinct identities may exceed maxUses, then block new payments', async () => { + const otherPayment = { + ...payment, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + transactionId: 'parallel-payment', + }; + + await Promise.all([ + promoCodesFactory.ensurePaymentAvailable(payment), + promoCodesFactory.ensurePaymentAvailable(otherPayment), + ]); + await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(otherPayment)]); + expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(2); + await expect(promoCodesFactory.ensurePaymentAvailable({ + ...payment, + userId: new ObjectId().toHexString(), + workspaceId: new ObjectId().toHexString(), + transactionId: 'later-payment', + })).rejects.toThrow('PROMO_CODE_INVALID'); }); }); diff --git a/test/sso/saml/controller.test.ts b/test/sso/saml/controller.test.ts index 307c9777..e8f6106c 100644 --- a/test/sso/saml/controller.test.ts +++ b/test/sso/saml/controller.test.ts @@ -170,6 +170,8 @@ describe('SamlController', () => { plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, + promoCodesFactory: {} as any, + promoCodeRedemptionsFactory: {} as any, }; /** From 8698c72f8eb0d06cade98678447426b1785ecfea Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 28 Oct 2026 15:54:25 +0300 Subject: [PATCH 11/12] chore --- docs/PromoCodes.md | 103 ------------------ ...013000000-create-promo-code-collections.js | 2 +- src/billing/promoCodes/index.ts | 2 +- src/models/promoCodesFactory.ts | 4 +- test/services/promoCodes/callbacks.test.ts | 2 +- test/services/promoCodes/index.test.ts | 2 +- test/services/promoCodes/redemptions.test.ts | 20 +++- 7 files changed, 23 insertions(+), 112 deletions(-) delete mode 100644 docs/PromoCodes.md diff --git a/docs/PromoCodes.md b/docs/PromoCodes.md deleted file mode 100644 index 224ca12e..00000000 --- a/docs/PromoCodes.md +++ /dev/null @@ -1,103 +0,0 @@ -# Promo code domain and factories - -`context.factories` exposes: - -- `promoCodesFactory.calculatePromoQuote(input: PromoCodeInput): Promise` -- `promoCodesFactory.ensurePaymentAvailable(payment: PromoPayment): Promise` -- `promoCodeRedemptionsFactory.redeem(payment: PromoPayment): Promise` - -Construct with `new PromoCodeRedemptionsFactory(db)` and -`new PromoCodesFactory(db, promoCodeRedemptionsFactory)`. Both use the accounts DB. -Types and pure `validatePromoPayment(payment)` / `ensurePromoCodeAvailable(...)` -are exported from `src/billing/promoCodes/redemptions.ts`. -`applyDiscount(discount, originalAmount)`, `ensureActivePromoCode(...)`, -`normalizePromoCode(...)`, `getMaxUses(...)`, `PromoCode`, and `PromoCodeDiscount` -are exported from `src/billing/promoCodes/index.ts`. - -```ts -interface PromoCodeInput { - promoCode: string; - userId: ObjectId; - workspaceId: ObjectId; - tariffPlanId: ObjectId; - originalAmount: number; -} - -interface PromoCodeQuote { - promoCodeId: ObjectId; - originalAmount: number; - finalAmount: number; -} - -interface PromoPayment { - promoCodeId: string; - originalAmount: number; - finalAmount: number; - userId: string; - workspaceId: string; - tariffPlanId: string; - transactionId: string; - paidAmount: number; - currency: string; -} -``` - -Only call payment methods with authenticated provider callbacks and verified signed -identity / quote fields. Pure validation does not verify the checksum or authorize -the payer. It validates IDs, a nonempty transaction ID, RUB currency, valid price -bounds, and exact equality of `finalAmount` and `paidAmount`. It never recalculates -a discount. There is no promo attempt ID or coupling to a business-operation ID. - -`ensurePaymentAvailable` reads the promo code once and checks its active flag, -plan restrictions, and successful usage. Call `redeem` only after success, not at -quote/check/failure time. It does not recheck current availability: a code may have -changed or another payment may have succeeded since the provider's check. -Duplicate usage is tolerated so the caller can still fulfill an actual successful -payment. Fulfillment and business-operation idempotency remain the caller's job. - -The shared `/check` creates operations through `businessOperationsFactory.create`, -using a new operation ID for each provider transaction. A declined attempt can be -retried in the same widget without regenerating the checksum. `/pay` validates the -checked operation, records usage and reuses ordinary fulfillment, notifications -and receipts (with the actual promo amount). `/fail` does not consume the code. -A redemption storage failure returns HTTP 500 with `PayCodes.RETRY`; ordinary -payments retain the master's acknowledgment and subscription-cancellation behavior. -No lease or exactly-once guarantee for fulfillment side effects is introduced. - -## Discount configuration and existing data - -New documents use `discount: { type: 'percent' | 'fixed', value: number }`. -Percentages must be integers from 1 to 99; fractional percentages are rejected to -avoid percentage subtraction precision errors. Fixed discounts are positive finite -amounts in RUB. Both round the final price up to whole rubles, with a 1 RUB floor. - -The existing `20261013000000-create-promo-code-collections.js` migration creates -indexes only. A minimal read fallback accepts legacy `discountPercent` when -`discount` is absent, without changing stored data. An explicitly present new -configuration takes precedence, even if invalid; it does not fall back silently. -Legacy fractional percentages must be corrected before making new quotes. - -No migration is required for rollout with this fallback. To remove it later, -backfill legacy documents with `discount: { type: 'percent', value: discountPercent }`, -validate the values, then remove `discountPercent`. Existing successful redemptions -remain usable; historical `discountPercent` fields are ignored. - -Checksums now contain `promo: { promoCodeId, originalAmount, finalAmount }`; -`composePayment.paymentAmount` remains unchanged for Garage. Existing master -purchase and card-link checksums remain compatible. Earlier feature-branch promo -checksums with top-level fields are not supported. If that build has already -processed payments on a deployed environment, coordinate in-flight and delayed -callbacks before replacing it; refresh open payment widgets. Stage verification -with real CloudPayments callbacks has not been performed. - -## Unresolved product decision: usage limits - -`maxUses` is a **soft limit** counting successful redemption records, not a -reservation. Concurrent payments can pass the check and then exceed it. Existing -unique indexes permit only one redemption per `(promoCodeId, userId)` and per -`(promoCodeId, workspaceId)`: parallel successful payments sharing either identity -retain one usage record, but both payments must still be fulfilled. - -No atomic reservations or hard-cap semantics are introduced. A hard limit needs -an explicit product decision about in-flight payments and paid-but-over-limit -behavior before implementation. The focused tests document the current race. diff --git a/migrations/20261013000000-create-promo-code-collections.js b/migrations/20261013000000-create-promo-code-collections.js index f0e15b25..e4ff73e0 100644 --- a/migrations/20261013000000-create-promo-code-collections.js +++ b/migrations/20261013000000-create-promo-code-collections.js @@ -11,7 +11,7 @@ module.exports = { */ async up(db) { await db.collection('promoCodes').createIndex({ - value: 1, + code: 1, }, { name: 'promoCode', unique: true, diff --git a/src/billing/promoCodes/index.ts b/src/billing/promoCodes/index.ts index d0c15012..c2034821 100644 --- a/src/billing/promoCodes/index.ts +++ b/src/billing/promoCodes/index.ts @@ -17,7 +17,7 @@ export interface PromoCode { /** * Uppercase code without surrounding whitespace. */ - value: string; + code: string; discount: PromoCodeDiscount; isDisabled?: boolean; /** diff --git a/src/models/promoCodesFactory.ts b/src/models/promoCodesFactory.ts index ec834bde..98a71166 100644 --- a/src/models/promoCodesFactory.ts +++ b/src/models/promoCodesFactory.ts @@ -27,7 +27,7 @@ export default class PromoCodesFactory { * @param input - requested payment with server-resolved identity and price */ public async calculatePromoQuote(input: PromoCodeInput): Promise { - const promoCode = await this.findActivePromoCode({ value: normalizePromoCode(input.promoCode) }, input.tariffPlanId); + const promoCode = await this.findActivePromoCode({ code: normalizePromoCode(input.promoCode) }, input.tariffPlanId); const finalAmount = applyDiscount(promoCode.discount, input.originalAmount); await this.redemptions.ensurePromoCodeAvailable(promoCode, input); @@ -58,7 +58,7 @@ export default class PromoCodesFactory { /** * Read legacy percentage documents without changing stored data. * - * @param query - indexed value or identifier lookup + * @param query - indexed code or identifier lookup * @param tariffPlanId - selected plan */ private async findActivePromoCode(query: Filter, tariffPlanId: ObjectId): Promise { diff --git a/test/services/promoCodes/callbacks.test.ts b/test/services/promoCodes/callbacks.test.ts index f0a962ff..9f88cee9 100644 --- a/test/services/promoCodes/callbacks.test.ts +++ b/test/services/promoCodes/callbacks.test.ts @@ -193,7 +193,7 @@ describe('Payment callbacks with MongoDB', () => { if (promo) { await db.collection('promoCodes').insertOne({ _id: promoCodeId, - value: 'PROMO', + code: 'PROMO', discount: { type: 'percent', value: 20, diff --git a/test/services/promoCodes/index.test.ts b/test/services/promoCodes/index.test.ts index 791d4567..4bc1e7f3 100644 --- a/test/services/promoCodes/index.test.ts +++ b/test/services/promoCodes/index.test.ts @@ -103,7 +103,7 @@ describe('Active promo code validation', () => { planId = new ObjectId(); promoCode = { _id: new ObjectId(), - value: 'SALE20', + code: 'SALE20', discount: { type: 'percent', value: 20, diff --git a/test/services/promoCodes/redemptions.test.ts b/test/services/promoCodes/redemptions.test.ts index e49052d4..5d7641b9 100644 --- a/test/services/promoCodes/redemptions.test.ts +++ b/test/services/promoCodes/redemptions.test.ts @@ -25,7 +25,7 @@ describe('Promo code factories and successful usage', () => { jest.resetAllMocks(); promoCode = { _id: new ObjectId(), - value: 'SALE20', + code: 'SALE20', discount: { type: 'percent', value: 20, @@ -87,7 +87,7 @@ describe('Promo code factories and successful usage', () => { expect(collection).toHaveBeenCalledWith('promoCodes'); expect(collection).toHaveBeenCalledWith('promoCodeRedemptions'); expect(findPromoCode).toHaveBeenCalledTimes(1); - expect(findPromoCode).toHaveBeenCalledWith({ value: 'SALE20' }); + expect(findPromoCode).toHaveBeenCalledWith({ code: 'SALE20' }); expect(findRedemption).toHaveBeenCalledWith({ promoCodeId: promoCode._id, $or: [ { userId: input.userId }, { workspaceId: input.workspaceId } ], @@ -379,7 +379,7 @@ describe('Promo factories with MongoDB unique indexes', () => { await db.collection('promoCodes').insertOne({ _id: promoCodeId, - value: 'SALE20', + code: 'SALE20', discount: { type: 'percent', value: 20, @@ -407,6 +407,20 @@ describe('Promo factories with MongoDB unique indexes', () => { } }); + test('should enforce uniqueness of code while allowing different codes', async () => { + const promoCodes = db.collection('promoCodes'); + + await promoCodes.insertOne({ code: 'OTHER' }); + await expect(promoCodes.insertOne({ code: 'SALE20' })).rejects.toMatchObject({ code: 11000 }); + await expect(promoCodesFactory.calculatePromoQuote({ + promoCode: ' sale20 ', + userId: new ObjectId(payment.userId), + workspaceId: new ObjectId(payment.workspaceId), + tariffPlanId: new ObjectId(payment.tariffPlanId), + originalAmount: 100, + })).resolves.toMatchObject({ finalAmount: 80 }); + }); + test('concurrent callback retries should persist exactly one usage', async () => { await Promise.all([redemptionsFactory.redeem(payment), redemptionsFactory.redeem(payment)]); expect(await db.collection('promoCodeRedemptions').countDocuments()).toBe(1); From 7b4bac50d4f0682acfe2e1efe5042fcc07222236 Mon Sep 17 00:00:00 2001 From: alisawavezen12 Date: Wed, 28 Oct 2026 16:02:29 +0300 Subject: [PATCH 12/12] Sign subscription dates in payment checksums --- src/billing/types/paymentData.ts | 4 ++- test/services/promoCodes/billingNew.test.ts | 27 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 29a04194..1740ab48 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -86,7 +86,9 @@ export interface PaymentData { */ promo?: PromoPaymentData; /** - * Next scheduled subscription payment date. + * Subscription start date signed by composePayment for plan purchases and card linking. + * Kept outside promo because regular payments use it too; promo /check also + * verifies the widget's recurrent startDate against this signed value. */ nextPaymentDate?: string; } diff --git a/test/services/promoCodes/billingNew.test.ts b/test/services/promoCodes/billingNew.test.ts index 21df8ce8..cd81b4db 100644 --- a/test/services/promoCodes/billingNew.test.ts +++ b/test/services/promoCodes/billingNew.test.ts @@ -159,7 +159,32 @@ describe('Promo payment resolver', () => { paymentAmount: 100, isCardLinkOperation: false, }); - expect(checksumService.parseAndVerifyChecksum(result.checksum)).not.toHaveProperty('promo'); + const signed = checksumService.parseAndVerifyChecksum(result.checksum); + + expect(signed).toMatchObject({ nextPaymentDate: result.nextPaymentDate.toISOString() }); + expect(signed).not.toHaveProperty('promo'); + expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); + }); + + test('ordinary card linking keeps the subscription date outside promo', async () => { + const { workspace, factories, context, input } = setup(); + + workspace.isTariffPlanExpired.mockReturnValue(false); + const result = await billingNew.Query.composePayment(undefined, { + input: { + ...input, + promoCode: undefined, + }, + }, context); + const signed = checksumService.parseAndVerifyChecksum(result.checksum); + + expect(result.isCardLinkOperation).toBe(true); + expect(signed).toEqual({ + workspaceId: workspace._id.toString(), + userId: context.user.id, + isCardLinkOperation: true, + nextPaymentDate: result.nextPaymentDate.toISOString(), + }); expect(factories.promoCodesFactory.calculatePromoQuote).not.toHaveBeenCalled(); });