From a0199b92684d380e84bccda8fc4e664756b4c082 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 23 Apr 2026 15:32:50 +0300 Subject: [PATCH 1/4] Handle card-link fail cases in CloudPayments Update CloudPayments /fail handling to support card-linking flows: replace PlanProlongationPayload with PaymentData, validate workspaceId and userId separately, and allow missing tariffPlanId when isCardLinkOperation is true (tariff is taken from workspace). Adjust error responses accordingly and keep previous validations for non-card-link operations. Tests updated to cover both scenarios: add a test that ensures a card-linking fail without tariffPlanId marks the business operation as Rejected, and a test that a non-card-link payload without tariffPlanId does not change the operation (remains Pending). Also remove an unused import and add jwt import for checksum generation in tests. --- src/billing/cloudpayments.ts | 26 +++++++-- test/integration/cases/billing/fail.test.ts | 62 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index bcee09e0..f03854f6 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -22,8 +22,7 @@ import { BusinessOperationType, ConfirmedMemberDBScheme, PayloadOfWorkspacePlanPurchase, - PlanDBScheme, - PlanProlongationPayload + PlanDBScheme } from '@hawk.so/types'; import WorkspaceModel from '../models/workspace'; import HawkCatcher from '@hawk.so/nodejs'; @@ -487,7 +486,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); @@ -507,12 +506,29 @@ subscription id: ${body.SubscriptionId}`; * @todo handle card linking and update business operation status */ - if (!data.workspaceId || !data.userId || !data.tariffPlanId) { - this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace or user id or plan id in request body`, body); + if (!data.workspaceId) { + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace id in request body`, body); return; } + if (!data.userId) { + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No user id in request body`, body); + + return; + } + + /** + * In card linking mode tariff plan id is taken from workspace. + */ + if (!data.isCardLinkOperation) { + if (!data.tariffPlanId) { + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No plan id in request body`, body); + + return; + } + } + try { businessOperation = await this.getBusinessOperation(req, body.TransactionId.toString()); workspace = await this.getWorkspace(req, data.workspaceId); diff --git a/test/integration/cases/billing/fail.test.ts b/test/integration/cases/billing/fail.test.ts index be1fd4f2..2b83c9e9 100644 --- a/test/integration/cases/billing/fail.test.ts +++ b/test/integration/cases/billing/fail.test.ts @@ -2,10 +2,11 @@ import { apiInstance } from '../../utils'; import { FailCodes, FailRequest } from '../../../../src/billing/types'; import { CardType, Currency, OperationType, ReasonCode, ReasonCodesTranscript } from '../../../../src/billing/types/enums'; import { Collection, ObjectId, Db } from 'mongodb'; -import { BusinessOperationDBScheme, BusinessOperationStatus, PlanDBScheme, BusinessOperationType, UserDBScheme, WorkspaceDBScheme, UserNotificationType, PlanProlongationPayload } from '@hawk.so/types'; +import { BusinessOperationDBScheme, BusinessOperationStatus, PlanDBScheme, BusinessOperationType, UserDBScheme, WorkspaceDBScheme, UserNotificationType } from '@hawk.so/types'; import { WorkerPaths } from '../../../../src/rabbitmq'; import { PaymentFailedNotificationTask, SenderWorkerTaskType } from '../../../../src/types/personalNotifications'; import checksumService from '../../../../src/utils/checksumService'; +import jwt, { Secret } from 'jsonwebtoken'; import type { Global } from '@jest/types'; declare var global: Global.Global; @@ -51,6 +52,7 @@ const tariffPlan: PlanDBScheme = { }; const planProlongationPayload = { + isCardLinkOperation: false, userId: user._id.toString(), workspaceId: workspace._id.toString(), tariffPlanId: tariffPlan._id.toString(), @@ -215,6 +217,35 @@ describe('Fail webhook', () => { expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); }); + + test('Should change business operation status to rejected for card linking payload without tariff plan id', async () => { + const apiResponse = await apiInstance.post('/billing/fail', { + ...validRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + isCardLinkOperation: true, + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + nextPaymentDate: new Date().toString(), + }), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + amount: 100, + startDate: new Date().toISOString(), + }, + }, + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Rejected); + }); }); describe('With invalid request', () => { @@ -236,6 +267,7 @@ describe('Fail webhook', () => { ...validRequest, Data: JSON.stringify({ checksum: await checksumService.generateChecksum({ + isCardLinkOperation: false, userId: '', workspaceId: workspace._id.toString(), tariffPlanId: tariffPlan._id.toString(), @@ -252,5 +284,33 @@ describe('Fail webhook', () => { expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); }); + + test('Should not change business operation status for non-card-link payload without tariff plan id', async () => { + const invalidChecksum = jwt.sign({ + isCardLinkOperation: false, + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + shouldSaveCard: false, + nextPaymentDate: new Date().toString(), + }, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, { expiresIn: '30m' }); + + const apiResponse = await apiInstance.post('/billing/fail', { + ...validRequest, + Data: JSON.stringify({ + checksum: invalidChecksum, + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + const message = await global.rabbitChannel.get(WorkerPaths.Email.queue, { + noAck: true, + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(message).toBeFalsy(); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); }); }); From c4c93457286780c5fedf9efd84786a54e228ac11 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:37:16 +0000 Subject: [PATCH 2/4] Bump version up to 1.4.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6db19b0b..8a2c557b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.12", + "version": "1.4.13", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From fa4dae066635ead1cca5f316bd4a25257e67f327 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 23 Apr 2026 15:37:56 +0300 Subject: [PATCH 3/4] Update cloudpayments.ts --- src/billing/cloudpayments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index f03854f6..5c4e7a4e 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -523,7 +523,7 @@ subscription id: ${body.SubscriptionId}`; */ if (!data.isCardLinkOperation) { if (!data.tariffPlanId) { - this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No plan id in request body`, body); + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No tariffPlanId in request body`, body); return; } From 9fdf85ff8d98f19cb44b7531889bca8d46716deb Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 23 Apr 2026 16:13:12 +0300 Subject: [PATCH 4/4] Update fail.test.ts --- test/integration/cases/billing/fail.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/cases/billing/fail.test.ts b/test/integration/cases/billing/fail.test.ts index 2b83c9e9..3d4ace12 100644 --- a/test/integration/cases/billing/fail.test.ts +++ b/test/integration/cases/billing/fail.test.ts @@ -52,7 +52,6 @@ const tariffPlan: PlanDBScheme = { }; const planProlongationPayload = { - isCardLinkOperation: false, userId: user._id.toString(), workspaceId: workspace._id.toString(), tariffPlanId: tariffPlan._id.toString(), @@ -267,7 +266,6 @@ describe('Fail webhook', () => { ...validRequest, Data: JSON.stringify({ checksum: await checksumService.generateChecksum({ - isCardLinkOperation: false, userId: '', workspaceId: workspace._id.toString(), tariffPlanId: tariffPlan._id.toString(),