diff --git a/package.json b/package.json index f213c04a..36b2e1e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.6", + "version": "1.1.7", "main": "index.ts", "license": "UNLICENSED", "scripts": { diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts index 9b543661..6b289b85 100644 --- a/src/billing/cloudpayments.ts +++ b/src/billing/cloudpayments.ts @@ -43,11 +43,12 @@ import { PaymentData } from './types/paymentData'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import PlanModel from '../models/plan'; import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments'; +import { ComposePaymentPayload } from './types/composePaymentPayload'; -/** - * Custom data of the plan prolongation request - */ -type PlanProlongationData = PlanProlongationPayload & PaymentData; +interface ComposePaymentRequest extends express.Request { + query: ComposePaymentPayload & { [key: string]: any }; + context: import('../types/graphql').ResolverContextBase; +}; /** * Class for describing the logic of payment routes @@ -99,8 +100,8 @@ export default class CloudPaymentsWebhooks { * @param req — Express request object * @param res - Express response object */ - private async composePayment(req: express.Request, res: express.Response): Promise { - const { workspaceId, tariffPlanId, shouldSaveCard } = req.query as Record; + private async composePayment(req: ComposePaymentRequest, res: express.Response): Promise { + const { workspaceId, tariffPlanId, shouldSaveCard } = req.query; const userId = req.context.user.id; if (!workspaceId || !tariffPlanId || !userId) { @@ -134,15 +135,23 @@ export default class CloudPaymentsWebhooks { } const invoiceId = this.generateInvoiceId(tariffPlan, workspace); + const isCardLinkOperation = workspace.tariffPlanId.toString() === tariffPlanId && !this.isPlanExpired(workspace); + let checksum; try { - checksum = await checksumService.generateChecksum({ + const checksumData = isCardLinkOperation ? { + isCardLinkOperation: true, + workspaceId: workspace._id.toString(), + userId: userId, + } : { workspaceId: workspace._id.toString(), userId: userId, tariffPlanId: tariffPlan._id.toString(), shouldSaveCard: shouldSaveCard === 'true', - }); + }; + + checksum = await checksumService.generateChecksum(checksumData); } catch (e) { const error = e as Error; @@ -158,11 +167,32 @@ export default class CloudPaymentsWebhooks { name: tariffPlan.name, monthlyCharge: tariffPlan.monthlyCharge, }, + isCardLinkOperation, currency: 'RUB', checksum, }); } + /** + * Returns true if workspace's plan is expired + * @param workspace - workspace to check + */ + private isPlanExpired(workspace: WorkspaceModel): boolean { + const lastChargeDate = new Date(workspace.lastChargeDate); + + let planExpiracyDate; + + if (workspace.isDebug) { + planExpiracyDate = lastChargeDate.setDate(lastChargeDate.getDate() + 1); + } else { + planExpiracyDate = lastChargeDate.setMonth(lastChargeDate.getMonth() + 1); + } + + const isPlanExpired = planExpiracyDate < Date.now(); + + return isPlanExpired; + } + /** * Generates invoice id for payment * @@ -201,7 +231,13 @@ export default class CloudPaymentsWebhooks { let member: ConfirmedMemberDBScheme; let plan: PlanDBScheme; - if (!data.workspaceId || !data.tariffPlanId || !data.userId) { + if (data.isCardLinkOperation && (!data.userId || !data.workspaceId)) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] Card linking – invalid data', body); + + return; + } + + if (!data.isCardLinkOperation && (!data.userId || !data.workspaceId || !data.tariffPlanId)) { this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); return; @@ -235,6 +271,18 @@ export default class CloudPaymentsWebhooks { return; } + if (data.isCardLinkOperation) { + telegram + .sendMessage(`✅ [Billing / Check] Card linked for subscription workspace «${workspace.name}»`, TelegramBotURLs.Money) + .catch(e => console.error('Error while sending message to Telegram: ' + e)); + + res.json({ + code: CheckCodes.SUCCESS, + } as CheckResponse); + + return; + } + /** * Create business operation about creation of subscription */ @@ -266,6 +314,7 @@ export default class CloudPaymentsWebhooks { 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); res.json({ @@ -720,9 +769,9 @@ export default class CloudPaymentsWebhooks { * * @param req - request with necessary data */ - private async getDataFromRequest(req: express.Request): Promise { + private async getDataFromRequest(req: express.Request): Promise { const context = req.context; - const body: CheckRequest = req.body; + const body: CheckRequest | PayRequest | FailRequest = req.body; /** * If Data is not presented in body means there is a recurring payment @@ -752,6 +801,7 @@ export default class CloudPaymentsWebhooks { tariffPlanId: workspace.tariffPlanId.toString(), userId, shouldSaveCard: false, + isCardLinkOperation: false, }; } diff --git a/src/billing/types/composePaymentPayload.ts b/src/billing/types/composePaymentPayload.ts new file mode 100644 index 00000000..91c3e1db --- /dev/null +++ b/src/billing/types/composePaymentPayload.ts @@ -0,0 +1,18 @@ +export interface ComposePaymentPayload { + /** + * Workspace Identifier + */ + workspaceId: string; + /** + * Id of the user making the payment + */ + userId: string; + /** + * Workspace current plan id or plan id to change + */ + tariffPlanId: string; + /** + * If true, we will save user card + */ + shouldSaveCard: 'true' | 'false'; +} diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts index 9ca61185..fb554dd9 100644 --- a/src/billing/types/paymentData.ts +++ b/src/billing/types/paymentData.ts @@ -40,4 +40,24 @@ export interface PaymentData { * Data for Cloudpayments needs */ cloudPayments?: CloudPaymentsSettings; + /** + * Workspace Identifier + */ + workspaceId: string; + /** + * Id of the user making the payment + */ + userId: string; + /** + * Workspace current plan id or plan id to change + */ + tariffPlanId: string; + /** + * If true, we will save user card + */ + shouldSaveCard: boolean; + /** + * True if this is card linking operation – charging minimal amount of money to validate card info + */ + isCardLinkOperation: boolean; } diff --git a/src/resolvers/billingNew.ts b/src/resolvers/billingNew.ts index a7bc4868..c3647f53 100644 --- a/src/resolvers/billingNew.ts +++ b/src/resolvers/billingNew.ts @@ -138,6 +138,11 @@ export default { */ async payWithCard(_obj: undefined, args: PayWithCardArgs, { factories, user }: ResolverContextWithUser): Promise { const paymentData = checksumService.parseAndVerifyChecksum(args.input.checksum); + + if (!('tariffPlanId' in paymentData)) { + throw new UserInputError('Invalid checksum'); + } + const fullUserInfo = await factories.usersFactory.findById(user.id); const workspace = await factories.workspacesFactory.findById(paymentData.workspaceId); diff --git a/src/utils/checksumService.ts b/src/utils/checksumService.ts index 16f1b58c..666ed0e7 100644 --- a/src/utils/checksumService.ts +++ b/src/utils/checksumService.ts @@ -1,6 +1,42 @@ import { PlanProlongationPayload } from '@hawk.so/types'; import jwt, { Secret } from 'jsonwebtoken'; +export type ChecksumData = PlanPurchaseChecksumData | CardLinkChecksumData; + +interface PlanPurchaseChecksumData { + /** + * Workspace Identifier + */ + workspaceId: string; + /** + * Id of the user making the payment + */ + userId: string; + /** + * Workspace current plan id or plan id to change + */ + tariffPlanId: string; + /** + * If true, we will save user card + */ + shouldSaveCard: boolean; +} + +interface CardLinkChecksumData { + /** + * Workspace Identifier + */ + workspaceId: string; + /** + * Id of the user making the payment + */ + userId: string; + /** + * True if this is card linking operation – charging minimal amount of money to validate card info + */ + isCardLinkOperation: boolean; +} + /** * Helper class for working with checksums */ @@ -10,7 +46,7 @@ class ChecksumService { * * @param data - data for processing billing request */ - public async generateChecksum(data: PlanProlongationPayload): Promise { + public async generateChecksum(data: ChecksumData): Promise { return jwt.sign( data, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret, @@ -23,20 +59,23 @@ class ChecksumService { * * @param checksum - checksum to parse */ - public parseAndVerifyChecksum(checksum: string): PlanProlongationPayload { - const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as PlanProlongationPayload; - - /** - * Filter unnecessary fields from JWT payload (e.g. "iat") - */ - const { tariffPlanId, workspaceId, userId, shouldSaveCard } = payload; + public parseAndVerifyChecksum(checksum: string): ChecksumData { + const payload = jwt.verify(checksum, process.env.JWT_SECRET_BILLING_CHECKSUM as Secret) as ChecksumData; - return { - tariffPlanId, - workspaceId, - userId, - shouldSaveCard, - }; + if ('isCardLinkOperation' in payload) { + return { + workspaceId: payload.workspaceId, + userId: payload.userId, + isCardLinkOperation: payload.isCardLinkOperation, + }; + } else { + return { + workspaceId: payload.workspaceId, + userId: payload.userId, + tariffPlanId: payload.tariffPlanId, + shouldSaveCard: payload.shouldSaveCard, + }; + } } }