diff --git a/jest-mongodb-config.js b/jest-mongodb-config.js index 27e27beb..8a0508d4 100644 --- a/jest-mongodb-config.js +++ b/jest-mongodb-config.js @@ -5,7 +5,7 @@ module.exports = { dbName: 'hawk', }, binary: { - version: '4.2.13', + version: '6.0.2', skipMD5: true, }, autoStart: false, diff --git a/package.json b/package.json index 7f90e788..948e30b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.3", + "version": "1.2.4", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 9d81b983..58090ffc 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -272,7 +272,10 @@ export default class CloudPaymentsWebhooks { try { await businessOperation.setStatus(BusinessOperationStatus.Confirmed); - await workspace.changePlan(tariffPlan._id); + + if (!data.isCardLinkOperation) { + await workspace.changePlan(tariffPlan._id); + } const subscriptionId = body.SubscriptionId; @@ -339,17 +342,22 @@ export default class CloudPaymentsWebhooks { * } */ - try { - await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ - type: 'unblock-workspace', - workspaceId: data.workspaceId, - })); - } catch (e) { - const error = e as Error; + /** + * If it is not a card linking operation then unblock workspace + */ + if (!data.isCardLinkOperation) { + try { + await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'unblock-workspace', + workspaceId: data.workspaceId, + })); + } catch (e) { + const error = e as Error; - this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body); + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body); - return; + return; + } } try { diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index c2aa1d2f..82fe2ab3 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -109,7 +109,21 @@ export default { const now = new Date(); const invoiceId = `${workspace.name} ${now.getDate()}/${now.getMonth() + 1} ${plan.name}`; - const isCardLinkOperation = workspace.tariffPlanId.toString() === tariffPlanId && !workspace.isTariffPlanExpired(); + let isCardLinkOperation = false; + + /** + * We need to only link card and not pay for the whole plan in case + * 1. We are paying for the same plan and + * 2. Plan is not expired and + * 3. Workspace is not blocked + */ + if ( + workspace.tariffPlanId.toString() === tariffPlanId && // 1 + !workspace.isTariffPlanExpired() && // 2 + !workspace.isBlocked // 3 + ) { + isCardLinkOperation = true; + } // Calculate next payment date const lastChargeDate = workspace.lastChargeDate ? new Date(workspace.lastChargeDate) : now; diff --git a/test/integration/cases/billing/pay.test.ts b/test/integration/cases/billing/pay.test.ts index b61f61e0..2dae594f 100644 --- a/test/integration/cases/billing/pay.test.ts +++ b/test/integration/cases/billing/pay.test.ts @@ -337,6 +337,26 @@ describe('Pay webhook', () => { expect(updatedWorkspace?.billingPeriodEventsCount).toBe(0); }); + test('Should not reset events counter in workspace if it is a card linking operation', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + ...paymentSuccessPayload, + isCardLinkOperation: true, + nextPaymentDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toString(), // next day + }), + }), + }); + + const notUpdatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(notUpdatedWorkspace?.billingPeriodEventsCount).not.toBe(0); + }); + test('Should reset last charge date in workspace', async () => { const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); @@ -375,6 +395,26 @@ describe('Pay webhook', () => { expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); }); + test('Should not send task to limiter worker if it is a card linking operation', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + ...paymentSuccessPayload, + isCardLinkOperation: true, + nextPaymentDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toString(), // next day + }), + }), + }); + + const message = await global.rabbitChannel.get('cron-tasks/limiter', { + noAck: true, + }); + + expect(message).toBeFalsy(); + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + }); + // test('Should associate an account with a workspace if the workspace did not have one', async () => { // /** // * Remove accountId from existed workspace @@ -479,6 +519,8 @@ describe('Pay webhook', () => { expect(updatedUser?.bankCards?.shift()).toMatchObject(expectedCard); expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); }); + + }); describe('With invalid request', () => { diff --git a/test/resolvers/billingNew.test.ts b/test/resolvers/billingNew.test.ts new file mode 100644 index 00000000..a42fafa3 --- /dev/null +++ b/test/resolvers/billingNew.test.ts @@ -0,0 +1,202 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; +import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; +import billingNewResolver from '../../src/resolvers/billingNew'; +import { ResolverContextWithUser } from '../../src/types/graphql'; + +// Set environment variables for test +process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret'; +process.env.JWT_SECRET_ACCESS_TOKEN = 'belarus'; +process.env.JWT_SECRET_REFRESH_TOKEN = 'abacaba'; +process.env.JWT_SECRET_PROJECT_TOKEN = 'qwerty'; + +/** + * Creates test data and mocks for composePayment tests + */ +function createComposePaymentTestSetup(options: { + isTariffPlanExpired?: boolean; + isBlocked?: boolean; + lastChargeDate?: Date; + planMonthlyCharge?: number; + planCurrency?: string; +}) { + const { + isTariffPlanExpired = false, + isBlocked = false, + lastChargeDate = new Date(), + planMonthlyCharge = 1000, + planCurrency = 'RUB' + } = options; + + const userId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); + const planId = new ObjectId().toString(); + + const plan: PlanDBScheme = { + _id: new ObjectId(planId), + name: 'Test Plan', + monthlyCharge: planMonthlyCharge, + monthlyChargeCurrency: planCurrency, + eventsLimit: 1000, + isDefault: false, + isHidden: false, + }; + + const workspace: WorkspaceDBScheme = { + _id: new ObjectId(workspaceId), + name: 'Test Workspace', + accountId: 'test-account-id', + balance: 0, + billingPeriodEventsCount: 0, + isBlocked, + lastChargeDate, + tariffPlanId: new ObjectId(planId), + inviteHash: 'test-invite-hash', + subscriptionId: undefined, + }; + + // Mock workspaces factory + const mockWorkspacesFactory = { + findById: jest.fn().mockResolvedValue({ + ...workspace, + getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }), + isTariffPlanExpired: jest.fn().mockReturnValue(isTariffPlanExpired), + isBlocked, + }), + }; + + // Mock plans factory + const mockPlansFactory = { + findById: jest.fn().mockResolvedValue(plan), + }; + + const mockContext: ResolverContextWithUser = { + user: { + id: userId, + accessTokenExpired: false, + }, + factories: { + workspacesFactory: mockWorkspacesFactory as any, + plansFactory: mockPlansFactory as any, + usersFactory: {} as any, + projectsFactory: {} as any, + businessOperationsFactory: {} as any, + }, + }; + + return { + userId, + workspaceId, + planId, + plan, + workspace, + mockContext, + mockWorkspacesFactory, + mockPlansFactory, + }; +} + +describe('GraphQLBillingNew', () => { + describe('composePayment', () => { + it('should return isCardLinkOperation = false in case of expired tariff plan', async () => { + // Create 2 months ago date + const expiredDate = new Date(); + expiredDate.setMonth(expiredDate.getMonth() - 2); + + const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + isTariffPlanExpired: true, + isBlocked: false, + lastChargeDate: expiredDate, + }); + + // Call composePayment resolver + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + shouldSaveCard: false, + }, + }, + mockContext + ); + + expect(result.isCardLinkOperation).toBe(false); + + // Check that nextPaymentDate is one month from now + const oneMonthFromNow = new Date(); + + oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1); + + const oneMonthFromNowStr = oneMonthFromNow.toISOString().split('T')[0]; + const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0]; + + expect(nextPaymentDateStr).toBe(oneMonthFromNowStr); + }); + + it('should return isCardLinkOperation = true in case of active tariff plan', async () => { + // Create 2 days ago date + const lastChargeDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + + const { mockContext, planId, workspaceId, workspace } = createComposePaymentTestSetup({ + isTariffPlanExpired: false, + isBlocked: false, + lastChargeDate, + }); + + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + shouldSaveCard: false, + }, + }, + mockContext + ); + + expect(result.isCardLinkOperation).toBe(true); + + const oneMonthFromLastChargeDate = new Date(workspace.lastChargeDate); + oneMonthFromLastChargeDate.setMonth(oneMonthFromLastChargeDate.getMonth() + 1); + + const oneMonthFromLastChargeDateStr = oneMonthFromLastChargeDate.toISOString().split('T')[0]; + const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0]; + expect(nextPaymentDateStr).toBe(oneMonthFromLastChargeDateStr); + }); + + it('should return isCardLinkOperation = false in case of blocked workspace', async () => { + const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({ + isTariffPlanExpired: false, + isBlocked: true, + lastChargeDate: new Date(), + }); + + const result = await billingNewResolver.Query.composePayment( + undefined, + { + input: { + workspaceId, + tariffPlanId: planId, + shouldSaveCard: false, + }, + }, + mockContext + ); + + expect(result.isCardLinkOperation).toBe(false); + + // Check that nextPaymentDate is one month from now + const oneMonthFromNow = new Date(); + + oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1); + + const oneMonthFromNowStr = oneMonthFromNow.toISOString().split('T')[0]; + const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0]; + + expect(nextPaymentDateStr).toBe(oneMonthFromNowStr); + }); + }); +})