Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jest-mongodb-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
dbName: 'hawk',
},
binary: {
version: '4.2.13',
version: '6.0.2',
skipMD5: true,
},
autoStart: false,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.3",
"version": "1.2.4",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
28 changes: 18 additions & 10 deletions src/billing/cloudpayments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
return router;
}

/**

Check warning on line 91 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Generates invoice id for payment
*
* @param tariffPlan - tariff plan to generate invoice id
Expand Down Expand Up @@ -205,7 +205,7 @@
telegram.sendMessage(`✅ [Billing / Check] All checks passed successfully «${workspace.name}»`, TelegramBotURLs.Money)
.catch(e => console.error('Error while sending message to Telegram: ' + e));

HawkCatcher.send(new Error('[Billing / Check] All checks passed successfully'), body as any);

Check warning on line 208 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: CheckCodes.SUCCESS,
Expand Down Expand Up @@ -272,7 +272,10 @@

try {
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);
await workspace.changePlan(tariffPlan._id);

if (!data.isCardLinkOperation) {
await workspace.changePlan(tariffPlan._id);
}

const subscriptionId = body.SubscriptionId;

Expand Down Expand Up @@ -339,17 +342,22 @@
* }
*/

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 {
Expand Down Expand Up @@ -547,7 +555,7 @@

this.handleSendingToTelegramError(telegram.sendMessage(`❌ [Billing / Fail] Transaction failed for «${workspace.name}»`, TelegramBotURLs.Money));

HawkCatcher.send(new Error('[Billing / Fail] Transaction failed'), body as any);

Check warning on line 558 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: FailCodes.SUCCESS,
Expand Down Expand Up @@ -729,7 +737,7 @@
* @param errorText - error description
* @param backtrace - request data and error data
*/
private sendError(res: express.Response, errorCode: CheckCodes | PayCodes | FailCodes | RecurrentCodes, errorText: string, backtrace: { [key: string]: any }): void {

Check warning on line 740 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type
res.json({
code: errorCode,
});
Expand Down Expand Up @@ -794,7 +802,7 @@
promise.catch(e => console.error('Error while sending message to Telegram: ' + e));
}

/**

Check warning on line 805 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Parses body and returns card data
* @param request - request body to parse
*/
Expand Down
16 changes: 15 additions & 1 deletion src/resolvers/billingNew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions test/integration/cases/billing/pay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -479,6 +519,8 @@ describe('Pay webhook', () => {
expect(updatedUser?.bankCards?.shift()).toMatchObject(expectedCard);
expect(apiResponse.data.code).toBe(PayCodes.SUCCESS);
});


});

describe('With invalid request', () => {
Expand Down
202 changes: 202 additions & 0 deletions test/resolvers/billingNew.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
})
Loading