Skip to content

Commit 323e42f

Browse files
Merge pull request #545 from codex-team/fix/card-linking
fix(payments): Do not unblock workspace in case of card linking payment
2 parents 821008b + ac04e0d commit 323e42f

File tree

6 files changed

+279
-13
lines changed

6 files changed

+279
-13
lines changed

jest-mongodb-config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module.exports = {
55
dbName: 'hawk',
66
},
77
binary: {
8-
version: '4.2.13',
8+
version: '6.0.2',
99
skipMD5: true,
1010
},
1111
autoStart: false,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.2.3",
3+
"version": "1.2.4",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/billing/cloudpayments.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,10 @@ export default class CloudPaymentsWebhooks {
272272

273273
try {
274274
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);
275-
await workspace.changePlan(tariffPlan._id);
275+
276+
if (!data.isCardLinkOperation) {
277+
await workspace.changePlan(tariffPlan._id);
278+
}
276279

277280
const subscriptionId = body.SubscriptionId;
278281

@@ -339,17 +342,22 @@ export default class CloudPaymentsWebhooks {
339342
* }
340343
*/
341344

342-
try {
343-
await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({
344-
type: 'unblock-workspace',
345-
workspaceId: data.workspaceId,
346-
}));
347-
} catch (e) {
348-
const error = e as Error;
345+
/**
346+
* If it is not a card linking operation then unblock workspace
347+
*/
348+
if (!data.isCardLinkOperation) {
349+
try {
350+
await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({
351+
type: 'unblock-workspace',
352+
workspaceId: data.workspaceId,
353+
}));
354+
} catch (e) {
355+
const error = e as Error;
349356

350-
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body);
357+
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending task to limiter worker ${error.toString()}`, body);
351358

352-
return;
359+
return;
360+
}
353361
}
354362

355363
try {

src/resolvers/billingNew.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,21 @@ export default {
109109
const now = new Date();
110110
const invoiceId = `${workspace.name} ${now.getDate()}/${now.getMonth() + 1} ${plan.name}`;
111111

112-
const isCardLinkOperation = workspace.tariffPlanId.toString() === tariffPlanId && !workspace.isTariffPlanExpired();
112+
let isCardLinkOperation = false;
113+
114+
/**
115+
* We need to only link card and not pay for the whole plan in case
116+
* 1. We are paying for the same plan and
117+
* 2. Plan is not expired and
118+
* 3. Workspace is not blocked
119+
*/
120+
if (
121+
workspace.tariffPlanId.toString() === tariffPlanId && // 1
122+
!workspace.isTariffPlanExpired() && // 2
123+
!workspace.isBlocked // 3
124+
) {
125+
isCardLinkOperation = true;
126+
}
113127

114128
// Calculate next payment date
115129
const lastChargeDate = workspace.lastChargeDate ? new Date(workspace.lastChargeDate) : now;

test/integration/cases/billing/pay.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,26 @@ describe('Pay webhook', () => {
337337
expect(updatedWorkspace?.billingPeriodEventsCount).toBe(0);
338338
});
339339

340+
test('Should not reset events counter in workspace if it is a card linking operation', async () => {
341+
const apiResponse = await apiInstance.post('/billing/pay', {
342+
...validPayRequestData,
343+
Data: JSON.stringify({
344+
checksum: await checksumService.generateChecksum({
345+
...paymentSuccessPayload,
346+
isCardLinkOperation: true,
347+
nextPaymentDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toString(), // next day
348+
}),
349+
}),
350+
});
351+
352+
const notUpdatedWorkspace = await workspacesCollection.findOne({
353+
_id: workspace._id,
354+
});
355+
356+
expect(apiResponse.data.code).toBe(PayCodes.SUCCESS);
357+
expect(notUpdatedWorkspace?.billingPeriodEventsCount).not.toBe(0);
358+
});
359+
340360
test('Should reset last charge date in workspace', async () => {
341361
const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData);
342362

@@ -375,6 +395,26 @@ describe('Pay webhook', () => {
375395
expect(apiResponse.data.code).toBe(PayCodes.SUCCESS);
376396
});
377397

398+
test('Should not send task to limiter worker if it is a card linking operation', async () => {
399+
const apiResponse = await apiInstance.post('/billing/pay', {
400+
...validPayRequestData,
401+
Data: JSON.stringify({
402+
checksum: await checksumService.generateChecksum({
403+
...paymentSuccessPayload,
404+
isCardLinkOperation: true,
405+
nextPaymentDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toString(), // next day
406+
}),
407+
}),
408+
});
409+
410+
const message = await global.rabbitChannel.get('cron-tasks/limiter', {
411+
noAck: true,
412+
});
413+
414+
expect(message).toBeFalsy();
415+
expect(apiResponse.data.code).toBe(PayCodes.SUCCESS);
416+
});
417+
378418
// test('Should associate an account with a workspace if the workspace did not have one', async () => {
379419
// /**
380420
// * Remove accountId from existed workspace
@@ -479,6 +519,8 @@ describe('Pay webhook', () => {
479519
expect(updatedUser?.bankCards?.shift()).toMatchObject(expectedCard);
480520
expect(apiResponse.data.code).toBe(PayCodes.SUCCESS);
481521
});
522+
523+
482524
});
483525

484526
describe('With invalid request', () => {

test/resolvers/billingNew.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import '../../src/env-test';
2+
import { ObjectId } from 'mongodb';
3+
import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types';
4+
import billingNewResolver from '../../src/resolvers/billingNew';
5+
import { ResolverContextWithUser } from '../../src/types/graphql';
6+
7+
// Set environment variables for test
8+
process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret';
9+
process.env.JWT_SECRET_ACCESS_TOKEN = 'belarus';
10+
process.env.JWT_SECRET_REFRESH_TOKEN = 'abacaba';
11+
process.env.JWT_SECRET_PROJECT_TOKEN = 'qwerty';
12+
13+
/**
14+
* Creates test data and mocks for composePayment tests
15+
*/
16+
function createComposePaymentTestSetup(options: {
17+
isTariffPlanExpired?: boolean;
18+
isBlocked?: boolean;
19+
lastChargeDate?: Date;
20+
planMonthlyCharge?: number;
21+
planCurrency?: string;
22+
}) {
23+
const {
24+
isTariffPlanExpired = false,
25+
isBlocked = false,
26+
lastChargeDate = new Date(),
27+
planMonthlyCharge = 1000,
28+
planCurrency = 'RUB'
29+
} = options;
30+
31+
const userId = new ObjectId().toString();
32+
const workspaceId = new ObjectId().toString();
33+
const planId = new ObjectId().toString();
34+
35+
const plan: PlanDBScheme = {
36+
_id: new ObjectId(planId),
37+
name: 'Test Plan',
38+
monthlyCharge: planMonthlyCharge,
39+
monthlyChargeCurrency: planCurrency,
40+
eventsLimit: 1000,
41+
isDefault: false,
42+
isHidden: false,
43+
};
44+
45+
const workspace: WorkspaceDBScheme = {
46+
_id: new ObjectId(workspaceId),
47+
name: 'Test Workspace',
48+
accountId: 'test-account-id',
49+
balance: 0,
50+
billingPeriodEventsCount: 0,
51+
isBlocked,
52+
lastChargeDate,
53+
tariffPlanId: new ObjectId(planId),
54+
inviteHash: 'test-invite-hash',
55+
subscriptionId: undefined,
56+
};
57+
58+
// Mock workspaces factory
59+
const mockWorkspacesFactory = {
60+
findById: jest.fn().mockResolvedValue({
61+
...workspace,
62+
getMemberInfo: jest.fn().mockResolvedValue({ isAdmin: true }),
63+
isTariffPlanExpired: jest.fn().mockReturnValue(isTariffPlanExpired),
64+
isBlocked,
65+
}),
66+
};
67+
68+
// Mock plans factory
69+
const mockPlansFactory = {
70+
findById: jest.fn().mockResolvedValue(plan),
71+
};
72+
73+
const mockContext: ResolverContextWithUser = {
74+
user: {
75+
id: userId,
76+
accessTokenExpired: false,
77+
},
78+
factories: {
79+
workspacesFactory: mockWorkspacesFactory as any,
80+
plansFactory: mockPlansFactory as any,
81+
usersFactory: {} as any,
82+
projectsFactory: {} as any,
83+
businessOperationsFactory: {} as any,
84+
},
85+
};
86+
87+
return {
88+
userId,
89+
workspaceId,
90+
planId,
91+
plan,
92+
workspace,
93+
mockContext,
94+
mockWorkspacesFactory,
95+
mockPlansFactory,
96+
};
97+
}
98+
99+
describe('GraphQLBillingNew', () => {
100+
describe('composePayment', () => {
101+
it('should return isCardLinkOperation = false in case of expired tariff plan', async () => {
102+
// Create 2 months ago date
103+
const expiredDate = new Date();
104+
expiredDate.setMonth(expiredDate.getMonth() - 2);
105+
106+
const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({
107+
isTariffPlanExpired: true,
108+
isBlocked: false,
109+
lastChargeDate: expiredDate,
110+
});
111+
112+
// Call composePayment resolver
113+
const result = await billingNewResolver.Query.composePayment(
114+
undefined,
115+
{
116+
input: {
117+
workspaceId,
118+
tariffPlanId: planId,
119+
shouldSaveCard: false,
120+
},
121+
},
122+
mockContext
123+
);
124+
125+
expect(result.isCardLinkOperation).toBe(false);
126+
127+
// Check that nextPaymentDate is one month from now
128+
const oneMonthFromNow = new Date();
129+
130+
oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1);
131+
132+
const oneMonthFromNowStr = oneMonthFromNow.toISOString().split('T')[0];
133+
const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0];
134+
135+
expect(nextPaymentDateStr).toBe(oneMonthFromNowStr);
136+
});
137+
138+
it('should return isCardLinkOperation = true in case of active tariff plan', async () => {
139+
// Create 2 days ago date
140+
const lastChargeDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
141+
142+
const { mockContext, planId, workspaceId, workspace } = createComposePaymentTestSetup({
143+
isTariffPlanExpired: false,
144+
isBlocked: false,
145+
lastChargeDate,
146+
});
147+
148+
const result = await billingNewResolver.Query.composePayment(
149+
undefined,
150+
{
151+
input: {
152+
workspaceId,
153+
tariffPlanId: planId,
154+
shouldSaveCard: false,
155+
},
156+
},
157+
mockContext
158+
);
159+
160+
expect(result.isCardLinkOperation).toBe(true);
161+
162+
const oneMonthFromLastChargeDate = new Date(workspace.lastChargeDate);
163+
oneMonthFromLastChargeDate.setMonth(oneMonthFromLastChargeDate.getMonth() + 1);
164+
165+
const oneMonthFromLastChargeDateStr = oneMonthFromLastChargeDate.toISOString().split('T')[0];
166+
const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0];
167+
expect(nextPaymentDateStr).toBe(oneMonthFromLastChargeDateStr);
168+
});
169+
170+
it('should return isCardLinkOperation = false in case of blocked workspace', async () => {
171+
const { mockContext, planId, workspaceId } = createComposePaymentTestSetup({
172+
isTariffPlanExpired: false,
173+
isBlocked: true,
174+
lastChargeDate: new Date(),
175+
});
176+
177+
const result = await billingNewResolver.Query.composePayment(
178+
undefined,
179+
{
180+
input: {
181+
workspaceId,
182+
tariffPlanId: planId,
183+
shouldSaveCard: false,
184+
},
185+
},
186+
mockContext
187+
);
188+
189+
expect(result.isCardLinkOperation).toBe(false);
190+
191+
// Check that nextPaymentDate is one month from now
192+
const oneMonthFromNow = new Date();
193+
194+
oneMonthFromNow.setMonth(oneMonthFromNow.getMonth() + 1);
195+
196+
const oneMonthFromNowStr = oneMonthFromNow.toISOString().split('T')[0];
197+
const nextPaymentDateStr = result.nextPaymentDate.toISOString().split('T')[0];
198+
199+
expect(nextPaymentDateStr).toBe(oneMonthFromNowStr);
200+
});
201+
});
202+
})

0 commit comments

Comments
 (0)