diff --git a/.env.sample b/.env.sample index 1e3ff48e..fc3de809 100644 --- a/.env.sample +++ b/.env.sample @@ -30,6 +30,19 @@ PLAYGROUND_ENABLE=false # AMQP URL AMQP_URL=amqp://guest:guest@rabbitmq +# Billing settings +BILLING_DEBUG=true +BILLING_COMPANY_EMAIL="team@hawk.so" + +### Accounting module ### +# Accounting service URL +# CODEX_ACCOUNTING_URL=http://accounting:3999/graphql + +# Files with certs +TLS_CA_CERT= +TLS_CERT= +TLS_KEY= + ## GitHub OAuth app client ID GITHUB_CLIENT_ID=fakedata @@ -51,6 +64,18 @@ HAWK_CATCHER_TOKEN= ## Telegram bot url to send log messages TELEGRAM_MAIN_CHAT_URL= +## Telegam bot url for operations with money +TELEGRAM_MONEY_CHAT_URL= + +# Cloudpayments public id +CLOUDPAYMENTS_PUBLIC_ID=test_api_00000000000000000000001 + +# Cloudpayments secret string +CLOUDPAYMENTS_SECRET= + +# INN of legal entity for CloudKassir +LEGAL_ENTITY_INN= + # Token for Amplitude analytics AMPLITUDE_TOKEN= diff --git a/.env.test b/.env.test index 07f83c40..5d1b2dcd 100644 --- a/.env.test +++ b/.env.test @@ -44,7 +44,7 @@ SMTP_SENDER_NAME= SMTP_SENDER_ADDRESS= # AMQP URL -AMQP_URL= +AMQP_URL=amqp://guest:guest@rabbitmq:5672/ # Billing settings BILLING_DEBUG=true @@ -52,7 +52,7 @@ BILLING_COMPANY_EMAIL="team@hawk.so" ### Accounting module ### # Accounting service URL -CODEX_ACCOUNTING_URL= +# CODEX_ACCOUNTING_URL= # Enable or disable tls verify TLS_VERIFY=true @@ -100,4 +100,4 @@ AWS_S3_ACCESS_KEY_ID= AWS_S3_SECRET_ACCESS_KEY= AWS_S3_BUCKET_NAME= AWS_S3_BUCKET_BASE_URL= -AWS_S3_BUCKET_ENDPOINT= \ No newline at end of file +AWS_S3_BUCKET_ENDPOINT= diff --git a/.eslintrc.js b/.eslintrc.js index 1fe24f29..00f90d1c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,5 +3,13 @@ module.exports = { env: { 'node': true, 'jest': true + }, + rules: { + '@typescript-eslint/camelcase': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/explicit-function-return-type': 'warn', + 'require-jsdoc': 'warn', + 'no-shadow': 'warn', + 'no-unused-expressions': 'warn' } }; diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index ad5b0955..31d6db85 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -39,9 +39,9 @@ jobs: - uses: actions/checkout@v2 # Setup node environment - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v3 with: - node-version: 15 + node-version-file: '.nvmrc' registry-url: https://registry.npmjs.org/ # Bump version to the next prerelease (patch) with rc suffix diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..9f895130 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,14 @@ +name: Run integration tests on push + +on: + - push + +jobs: + tests: + name: Run integration tests + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + + - name: Run tests + run: yarn test:integration diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 34d856cc..5805844f 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -8,9 +8,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Use Node.js 12.x - uses: actions/setup-node@v1 + - name: Use Node.js + uses: actions/setup-node@v3 with: - node-version: 12.x + node-version-file: '.nvmrc' - run: yarn install - run: yarn lint-test diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..ac51dae6 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,63 @@ +version: "3.4" +services: + api: + build: + dockerfile: "./docker/Dockerfile.dev" + context: . + user: "node" + env_file: + - ./test/integration/api.env + volumes: + - ./:/usr/src/app + - /usr/src/app/node_modules + - ./test/integration/api.env:/usr/src/app/.env + depends_on: + - mongodb + - rabbitmq + # - accounting + stdin_open: true + tty: true + + mongodb: + image: mongo:4.2.13 + volumes: + - mongodata-test:/data/db + + tests: + build: + dockerfile: "./docker/Dockerfile.dev" + context: . + depends_on: + rabbitmq: + condition: service_healthy + api: + condition: service_started + command: dockerize -wait http://api:4000/.well-known/apollo/server-health -timeout 30s yarn jest --config=./test/integration/jest.config.js --runInBand test/integration + volumes: + - ./:/usr/src/app + - /usr/src/app/node_modules + + rabbitmq: + image: rabbitmq:3-management + ports: + - 15672:15672 + - 5672:5672 + volumes: + - ./test/integration/rabbit.definitions.json:/tmp/rabbit.definitions.json:ro + environment: + - RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbitmq_management load_definitions "/tmp/rabbit.definitions.json" + healthcheck: + test: ["CMD-SHELL", "rabbitmqctl status || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + + # accounting: + # image: codexteamuser/codex-accounting:prod + # env_file: + # - ./test/integration/accounting.env + # volumes: + # - ./test/integration/accounting.env:/usr/src/app/.env + +volumes: + mongodata-test: diff --git a/migrations/20241110143438-add-monthlyChargeCurrency.js b/migrations/20241110143438-add-monthlyChargeCurrency.js new file mode 100644 index 00000000..d33952ad --- /dev/null +++ b/migrations/20241110143438-add-monthlyChargeCurrency.js @@ -0,0 +1,45 @@ +module.exports = { + async up(db, client) { + /** + * Use one transaction for all requests + */ + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const plansCollection = db.collection('plans'); + + await plansCollection.updateMany( + {}, + { $set: { monthlyChargeCurrency: 'RUB' } } // Set the default value for monthlyChargeCurrency + ); + }); + + } finally { + await session.endSession(); + } + }, + + async down(db, client) { + /** + * Use one transaction for all requests + */ + const session = client.startSession(); + + try { + + await session.withTransaction(async () => { + const plansCollection = db.collection('plans'); + + await plansCollection.updateMany( + {}, + { $unset: { monthlyChargeCurrency: "" } } // Remove the monthlyChargeCurrency field + ); + + }); + + } finally { + await session.endSession(); + } + } +}; diff --git a/migrations/20241110161019-add-businessOperations-currency.js b/migrations/20241110161019-add-businessOperations-currency.js new file mode 100644 index 00000000..620c7313 --- /dev/null +++ b/migrations/20241110161019-add-businessOperations-currency.js @@ -0,0 +1,44 @@ +module.exports = { + async up(db, client) { + /** + * Use one transaction for all requests + */ + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const businessOperationsCollection = db.collection('businessOperations'); + + await businessOperationsCollection.updateMany( + {}, + { $set: { 'payload.currency': 'RUB' } } // Set the default value for payload.currency + ); + }); + + } finally { + await session.endSession(); + } + }, + + async down(db, client) { + /** + * Use one transaction for all requests + */ + const session = client.startSession(); + + try { + + await session.withTransaction(async () => { + const businessOperationsCollection = db.collection('businessOperations'); + + await businessOperationsCollection.updateMany( + {}, + { $unset: { 'payload.businessOperations': "" } } // Remove the businessOperations field + ); + }); + + } finally { + await session.endSession(); + } + } +}; diff --git a/package.json b/package.json index 91016128..4966ab91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.0.23", + "version": "1.1.1", "main": "index.ts", "license": "UNLICENSED", "scripts": { @@ -14,14 +14,18 @@ "migrations:create": "docker-compose exec api yarn migrate-mongo create", "migrations:up": "docker-compose exec api yarn migrate-mongo up", "migrations:down": "docker-compose exec api yarn migrate-mongo down", - "test": "jest --coverage" + "test": "jest --coverage", + "test:integration": "docker compose -f docker-compose.test.yml up --build --exit-code-from tests tests", + "test:integration:down": "docker compose -f docker-compose.test.yml down --volumes" }, "devDependencies": { + "@shelf/jest-mongodb": "^1.2.2", "@types/jest": "^26.0.8", "eslint": "^6.7.2", "eslint-config-codex": "1.2.4", "eslint-plugin-import": "^2.19.1", "jest": "^26.2.2", + "mongodb-memory-server": "^6.6.1", "nodemon": "^2.0.2", "ts-jest": "^26.1.4", "ts-node": "^10.9.1", @@ -33,7 +37,7 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.1.18", + "@hawk.so/types": "^0.1.21", "@types/amqp-connection-manager": "^2.0.4", "@types/bson": "^4.0.5", "@types/debug": "^4.1.5", @@ -53,6 +57,8 @@ "axios": "^0.27.2", "body-parser": "^1.19.0", "bson": "^4.6.5", + "cloudpayments": "^6.0.1", + "codex-accounting-sdk": "https://github.com/codex-team/codex-accounting-sdk.git", "dataloader": "^2.0.0", "dotenv": "^16.0.1", "escape-html": "^1.0.3", diff --git a/src/billing/cloudpayments.ts b/src/billing/cloudpayments.ts new file mode 100644 index 00000000..ff41abe3 --- /dev/null +++ b/src/billing/cloudpayments.ts @@ -0,0 +1,818 @@ +import express from 'express'; +import * as telegram from '../utils/telegram'; +import { TelegramBotURLs } from '../utils/telegram'; +import { + CheckCodes, + CheckRequest, + CheckResponse, + FailCodes, + FailRequest, + FailResponse, + PayCodes, + PayRequest, + PayResponse, + RecurrentCodes, + RecurrentRequest, + RecurrentResponse +} from './types'; +import { ReasonCodesTranscript, SubscriptionStatus } from './types/enums'; +import { + BankCard, + BusinessOperationStatus, + BusinessOperationType, + ConfirmedMemberDBScheme, + PayloadOfWorkspacePlanPurchase, + PlanDBScheme, + PlanProlongationPayload +} from '@hawk.so/types'; +import { PENNY_MULTIPLIER } from 'codex-accounting-sdk'; +import WorkspaceModel from '../models/workspace'; +import HawkCatcher from '@hawk.so/nodejs'; +import { publish } from '../rabbitmq'; +import sendNotification from '../utils/personalNotifications'; +import { + PaymentFailedNotificationTask, + PaymentSuccessNotificationTask, + SenderWorkerTaskType +} from '../types/userNotifications'; +import BusinessOperationModel from '../models/businessOperation'; +import UserModel from '../models/user'; +import checksumService from '../utils/checksumService'; +import { WebhookData } from './types/request'; +import { PaymentData } from './types/paymentData'; +import cloudPaymentsApi from '../utils/cloudPaymentsApi'; +import PlanModel from '../models/plan'; +import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments'; + +/** + * Custom data of the plan prolongation request + */ +type PlanProlongationData = PlanProlongationPayload & PaymentData; + +/** + * Class for describing the logic of payment routes + */ +export default class CloudPaymentsWebhooks { + private readonly clientService = new ClientService({ + publicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', + privateKey: process.env.CLOUDPAYMENTS_SECRET || '', + }) + + /** + * Receipt API instance to call receipt methods + */ + private readonly receiptApi: ReceiptApi; + + /** + * Client API instance to call CloundPayments API + */ + private readonly clientApi: ClientApi; + + /** + * Creates class instance + */ + constructor() { + this.receiptApi = this.clientService.getReceiptApi(); + this.clientApi = this.clientService.getClientApi(); + } + + /** + * Returns router for payments + * + * @returns - express router for payments + */ + public getRouter(): express.Router { + const router = express.Router(); + + router.get('/compose-payment', this.composePayment.bind(this)); + router.all('/check', this.check.bind(this)); + router.all('/pay', this.pay.bind(this)); + router.all('/fail', this.fail.bind(this)); + router.all('/recurrent', this.recurrent.bind(this)); + + return router; + } + + /** + * Prepares payment data before charge + * + * @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; + const userId = req.context.user.id; + + if (!workspaceId || !tariffPlanId || !userId) { + this.sendError(res, 1, `[Billing / Compose payment] No workspace, tariff plan or user id in request body`, req.query); + + return; + } + + let workspace; + let tariffPlan; + + try { + workspace = await this.getWorkspace(req, workspaceId); + tariffPlan = await this.getPlan(req, tariffPlanId); + } catch (e) { + const error = e as Error; + + this.sendError(res, 1, `[Billing / Compose payment] Can't get data from Database ${error.toString()}`, req.query); + + return; + } + + try { + await this.getMember(userId, workspace); + } catch (e) { + const error = e as Error; + + this.sendError(res, 1, `[Billing / Compose payment] Can't compose payment due to error: ${error.toString()}`, req.query); + + return; + } + const invoiceId = this.generateInvoiceId(tariffPlan, workspace); + + let checksum; + + try { + checksum = await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: userId, + tariffPlanId: tariffPlan._id.toString(), + shouldSaveCard: shouldSaveCard === 'true', + }); + } catch (e) { + const error = e as Error; + + this.sendError(res, 1, `[Billing / Compose payment] Can't generate checksum: ${error.toString()}`, req.query); + + return; + } + + res.send({ + invoiceId, + plan: { + id: tariffPlan._id.toString(), + name: tariffPlan.name, + monthlyCharge: tariffPlan.monthlyCharge, + }, + currency: 'RUB', + checksum, + }); + } + + /** + * Generates invoice id for payment + * + * @param tariffPlan - tariff plan to generate invoice id + * @param workspace - workspace data to generate invoice id + */ + private generateInvoiceId(tariffPlan: PlanDBScheme, workspace: WorkspaceModel): string { + const now = new Date(); + + return `${workspace.name} ${now.getDate()}/${now.getMonth() + 1} ${tariffPlan.name}`; + } + + /** + * Route to confirm the correctness of a user's payment + * https://developers.cloudpayments.ru/#check + * + * @param req - cloudpayments request with payment details + * @param res - check result code + */ + private async check(req: express.Request, res: express.Response): Promise { + const context = req.context; + const body: CheckRequest = req.body; + let data; + + try { + data = await this.getDataFromRequest(req); + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Invalid request: ${error.toString()}`, body); + + return; + } + + let workspace: WorkspaceModel; + let member: ConfirmedMemberDBScheme; + let plan: PlanDBScheme; + + if (!data.workspaceId || !data.tariffPlanId || !data.userId) { + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the request', body); + + return; + } + + const { workspaceId, userId, tariffPlanId } = data; + + try { + workspace = await this.getWorkspace(req, workspaceId); + member = await this.getMember(userId, workspace); + plan = await this.getPlan(req, tariffPlanId); + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] ${error.toString()}`, body); + + return; + } + + const recurrentPaymentSettings = data.cloudPayments?.recurrent; + + /** + * The amount will be considered correct if it is equal to the cost of the tariff plan. + * Also, the cost will be correct if it is a payment to activate the subscription. + */ + const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; + + if (!isRightAmount) { + this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); + + return; + } + + /** + * Create business operation about creation of subscription + */ + try { + await context.factories.businessOperationsFactory.create({ + transactionId: body.TransactionId.toString(), + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Pending, + payload: { + workspaceId: workspace._id, + amount: +body.Amount * PENNY_MULTIPLIER, + currency: body.Currency, + userId: member._id, + tariffPlanId: plan._id, + }, + dtCreated: new Date(), + }); + } catch (err) { + const error = err as Error; + + this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Business operation wasn't created: ${error.toString()}`, body); + + res.json({ + code: CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, + } as CheckResponse); + + return; + } + + 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({ + code: CheckCodes.SUCCESS, + } as CheckResponse); + } + + /** + * Route for fixing a successful payment + * https://developers.cloudpayments.ru/#pay + * + * @param req - cloudpayments request with payment details + * @param res - result code + */ + private async pay(req: express.Request, res: express.Response): Promise { + const body: PayRequest = req.body; + let data; + + try { + data = await this.getDataFromRequest(req); + } catch (e) { + const error = e as Error; + + this.sendError(res, CheckCodes.SUCCESS, `[Billing / Pay] Invalid request: ${error.toString()}`, body); + + return; + } + + if (!data.workspaceId || !data.tariffPlanId || !data.userId) { + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] No workspace, tariff plan or user id in request body`, body); + + return; + } + + let businessOperation; + let workspace; + let tariffPlan; + let user; + + try { + businessOperation = await this.getBusinessOperation(req, body.TransactionId.toString()); + workspace = await this.getWorkspace(req, data.workspaceId); + tariffPlan = await this.getPlan(req, data.tariffPlanId); + user = await this.getUser(req, data.userId); + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't get data from Database ${error.toString()}`, body); + + return; + } + + try { + await businessOperation.setStatus(BusinessOperationStatus.Confirmed); + await workspace.resetBillingPeriod(); + await workspace.changePlan(tariffPlan._id); + + const subscriptionId = body.SubscriptionId; + + /** + * Cancellation of the current subscription if: + * 1) the user pays manually (the workspace has an active subscription, but the request body does not) + * 2) if payment is made for another subscription (subscriptions id are not equal) + */ + if (workspace.subscriptionId) { + if (!subscriptionId || subscriptionId !== workspace.subscriptionId) { + await this.clientApi.cancelSubscription({ + Id: workspace.subscriptionId, + }); + } + } + + if (subscriptionId) { + await workspace.setSubscriptionId(subscriptionId); + } + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body); + + return; + } + + // let accountId = workspace.accountId; + + /* + * try { + * if (!workspace.accountId) { + * accountId = (await context.accounting.createAccount({ + * name: `WORKSPACE:${workspace.name}`, + * type: AccountType.LIABILITY, + * currency: Currency.RUB, + * })).recordId; + * await workspace.setAccountId(accountId); + * } + */ + + /* + * await context.accounting.payOnce({ + * accountId: accountId, + * amount: tariffPlan.monthlyCharge * PENNY_MULTIPLIER, + * description: `Account replenishment to pay for the tariff plan with id ${tariffPlan._id}. CloudPayments transaction ID: ${body.TransactionId}`, + * }); + */ + + /* + * await context.accounting.purchase({ + * accountId, + * amount: tariffPlan.monthlyCharge * PENNY_MULTIPLIER, + * description: `Charging for tariff plan with id ${tariffPlan._id}. CloudPayments transaction ID: ${body.TransactionId}`, + * }); + * } catch (e) { + * const error = e as Error; + */ + + // this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body); + + /* + * return; + * } + */ + + try { + await publish('cron-tasks', 'cron-tasks/limiter', JSON.stringify({ + type: 'check-single-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); + + return; + } + + try { + // todo: add plan-prolongation notification if it was a payment by subscription + const senderWorkerTask: PaymentSuccessNotificationTask = { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + workspaceId: data.workspaceId, + tariffPlanId: data.tariffPlanId, + userId: data.userId, + }, + }; + + await sendNotification(user, senderWorkerTask); + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while sending notification to the user ${error.toString()}`, body); + + return; + } + + try { + if (data.shouldSaveCard) { + const cardData = this.getCardData(body); + + if (cardData) { + await user.saveNewBankCard(cardData); + } + } + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while saving user card: ${error.toString()}`, body); + + return; + } + + try { + /** + * Cancel payment if it is deferred + */ + if (data.cloudPayments?.recurrent?.startDate) { + this.handleSendingToTelegramError(telegram.sendMessage(`✅ [Billing / Pay] Recurrent payments activated for «${workspace.name}». 1 RUB charged`, TelegramBotURLs.Money)); + await cloudPaymentsApi.cancelPayment(body.TransactionId); + this.handleSendingToTelegramError(telegram.sendMessage(`✅ [Billing / Pay] Recurrent payments activated for «${workspace.name}». 1 RUB returned`, TelegramBotURLs.Money)); + } else { + /** + * Russia code from ISO 3166-1 + */ + const RUSSIA_ISO_CODE = 'RU'; + + /** + * Send receipt only in case that user pays from russian card + */ + const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined; + + await this.sendReceipt(workspace, tariffPlan, userEmail); + + this.handleSendingToTelegramError(telegram.sendMessage(`✅ [Billing / Pay] Payment passed successfully for «${workspace.name}»`, TelegramBotURLs.Money)); + } + } catch (e) { + const error = e as Error; + + this.sendError(res, PayCodes.SUCCESS, error.toString(), body); + + return; + } + + res.json({ + code: PayCodes.SUCCESS, + } as PayResponse); + } + + /** + * Route for refused payments + * https://developers.cloudpayments.ru/#fail + * + * @param req - cloudpayments request with payment details + * @param res - result code + */ + private async fail(req: express.Request, res: express.Response): Promise { + const body: FailRequest = req.body; + let data: PlanProlongationPayload; + + try { + data = await this.getDataFromRequest(req); + } catch (e) { + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request`, body); + + return; + } + + let businessOperation; + let workspace; + let user; + + 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); + + return; + } + + try { + businessOperation = await this.getBusinessOperation(req, body.TransactionId.toString()); + workspace = await this.getWorkspace(req, data.workspaceId); + user = await this.getUser(req, data.userId); + } catch (e) { + const error = e as Error; + + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] ${error.toString()}`, body); + + return; + } + + try { + await businessOperation.setStatus(BusinessOperationStatus.Rejected); + } catch (e) { + const error = e as Error; + + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Can't update business operation status ${error.toString()}`, body); + + return; + } + + try { + const senderWorkerTask: PaymentFailedNotificationTask = { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + workspaceId: data.workspaceId, + reason: ReasonCodesTranscript[body.ReasonCode], + }, + }; + + await sendNotification(user, senderWorkerTask); + } catch (e) { + const error = e as Error; + + this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Error while sending notification to the user ${error.toString()}`, body); + + return; + } + + this.handleSendingToTelegramError(telegram.sendMessage(`✅ [Billing / Fail] Transaction failed for «${workspace.name}»`, TelegramBotURLs.Money)); + + HawkCatcher.send(new Error('[Billing / Fail] Transaction failed'), body as any); + + res.json({ + code: FailCodes.SUCCESS, + } as FailResponse); + } + + /** + * Route is executed if the status of the recurring payment subscription has been changed. + * https://developers.cloudpayments.ru/#recurrent + * + * @param req - cloudpayments request with subscription details + * @param res - result code + */ + private async recurrent(req: express.Request, res: express.Response): Promise { + const body: RecurrentRequest = req.body; + const context = req.context; + + this.handleSendingToTelegramError(telegram.sendMessage(`[Billing / Recurrent] New recurrent event with ${body.Status} status`, TelegramBotURLs.Money)); + HawkCatcher.send(new Error(`[Billing / Recurrent] New recurrent event with ${body.Status} status`), req.body); + + switch (body.Status) { + case SubscriptionStatus.CANCELLED: + case SubscriptionStatus.REJECTED: { + let workspace; + + try { + workspace = await context.factories.workspacesFactory.findBySubscriptionId(body.Id); + } catch (e) { + const error = e as Error; + + this.sendError(res, RecurrentCodes.SUCCESS, `[Billing / Recurrent] Can't get data from database: ${error.toString()}`, { + body, + workspace, + }); + + return; + } + + if (!workspace) { + return; + } + + try { + await workspace.setSubscriptionId(null); + } catch (e) { + const error = e as Error; + + this.sendError(res, RecurrentCodes.SUCCESS, `[Billing / Recurrent] Can't remove subscriptionId from workspace: ${error.toString()}`, { + body, + workspace, + }); + } + } + } + + res.json({ + code: RecurrentCodes.SUCCESS, + } as RecurrentResponse); + } + + /** + * Get workspace by workspace id + * + * @param req - express request + * @param workspaceId - id of workspace + */ + private async getWorkspace(req: express.Request, workspaceId: string): Promise { + const workspace = await req.context.factories.workspacesFactory.findById(workspaceId); + + if (!workspace) { + throw new Error('Workspace not found'); + } + + return workspace; + } + + /** + * Get user by its id + * + * @param req - express request + * @param userId - id of user to fetch + */ + private async getUser(req: express.Request, userId: string): Promise { + const user = await req.context.factories.usersFactory.findById(userId); + + if (!user) { + throw new Error('User not found'); + } + + return user; + } + + /** + * Get business operation by transaction id + * + * @param req - express request + * @param transactionId - id of the transaction for fetching business operation + */ + private async getBusinessOperation(req: express.Request, transactionId: string): Promise { + const businessOperation = await req.context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId); + + if (!businessOperation) { + throw new Error('Business operation not found'); + } + + return businessOperation; + } + + /** + * Get member info + * + * @param userId - id of current user + * @param workspace - workspace data + */ + private async getMember(userId: string, workspace: WorkspaceModel): Promise { + const user = await workspace.getMemberInfo(userId); + + if (!user) { + throw new Error('User not found'); + } + + if (!user || WorkspaceModel.isPendingMember(user)) { + throw new Error('User cannot pay for current workspace because he is not a member of it'); + } + + if (!user.isAdmin) { + throw new Error('User cannot pay for current workspace because he is not an admin'); + } + + return user; + } + + /** + * Get workspace plan + * + * @param req - express request + * @param tariffPlanId - plan id + */ + private async getPlan(req: express.Request, tariffPlanId: string): Promise { + const plan = await req.context.factories.plansFactory.findById(tariffPlanId); + + if (!plan) { + throw new Error('Plan not found'); + } + + return plan; + } + + /** + * Send an error to telegram, Hawk and send an express response with the error code + * + * @param res - Express response + * @param errorCode - code of error + * @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 { + res.json({ + code: errorCode, + }); + + this.handleSendingToTelegramError(telegram.sendMessage(`❌ ${errorText}`, TelegramBotURLs.Money)); + + HawkCatcher.send(new Error(errorText), backtrace); + } + + /** + * Parses request body and returns data from it + * + * @param req - request with necessary data + */ + private async getDataFromRequest(req: express.Request): Promise { + const context = req.context; + const body: CheckRequest = req.body; + + /** + * If Data is not presented in body means there is a recurring payment + * Data field is presented only in one-time payment requests or subscription initial request + */ + if (body.Data) { + const parsedData = JSON.parse(body.Data || '{}') as WebhookData; + + return { + ...checksumService.parseAndVerifyChecksum(parsedData.checksum), + ...parsedData, + }; + } + + const subscriptionId = body.SubscriptionId; + const userId = body.AccountId; + + if (!subscriptionId || !userId) { + throw new Error('Invalid request: no subscription or user id'); + } + + const workspace = await context.factories.workspacesFactory.findBySubscriptionId(subscriptionId); + + if (workspace) { + return { + workspaceId: workspace._id.toString(), + tariffPlanId: workspace.tariffPlanId.toString(), + userId, + shouldSaveCard: false, + }; + } + + throw new Error('Invalid request: no necessary data'); + } + + /** + * Wrapper for telegram promise + * @param promise - promise to handle + */ + private handleSendingToTelegramError(promise: Promise): void { + promise.catch(e => console.error('Error while sending message to Telegram: ' + e)); + } + + /** + * Parses body and returns card data + * @param request - request body to parse + */ + private getCardData(request: PayRequest): Omit | null { + if (!request.CardType || !request.CardExpDate || !request.CardLastFour || !request.CardFirstSix || !request.Token) { + return null; + } + + return { + cardExpDate: request.CardExpDate, + firstSix: +request.CardFirstSix, + lastFour: +request.CardLastFour, + token: request.Token, + type: request.CardType, + }; + } + + /** + * Send receipt to user after successful payment + * + * @param workspace - workspace for which payment is made + * @param tariff - paid tariff plan + * @param userMail - user email address + */ + private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string): Promise { + /** + * A general tax that applies to all commercial activities + * involving the production and distribution of goods and the provision of services + * Also known as "НДС" in Russia + */ + const VALUE_ADDED_TAX = 20; + + const item: CustomerReceiptItem = { + amount: tariff.monthlyCharge, + label: `${tariff.name} tariff plan`, + price: tariff.monthlyCharge, + // vat: VALUE_ADDED_TAX, + quantity: 1, + }; + + await this.receiptApi.createReceipt( + { + Type: ReceiptTypes.Income, + Inn: Number(process.env.LEGAL_ENTITY_INN), + }, + { + Items: [ item ], + email: userMail, + taxationSystem: TaxationSystem.GENERAL, + } + ); + } +} diff --git a/src/billing/index.ts b/src/billing/index.ts new file mode 100644 index 00000000..fbe27090 --- /dev/null +++ b/src/billing/index.ts @@ -0,0 +1,20 @@ +import CloudPaymentsWebhooks from './cloudpayments'; +import express from 'express'; +import cors from 'cors'; + +/** + * Hawk billing + */ +export default class Billing { + /** + * Append billing routes to the express app + * + * @param app - express app + */ + public appendRoutes(app: express.Application): void { + const providerWebhooks = new CloudPaymentsWebhooks(); + + app.use(cors()); + app.use('/billing', providerWebhooks.getRouter()); + } +} diff --git a/src/billing/types/cardDetails.ts b/src/billing/types/cardDetails.ts new file mode 100644 index 00000000..ae41a5bb --- /dev/null +++ b/src/billing/types/cardDetails.ts @@ -0,0 +1,31 @@ +import { CardType } from './enums'; + +/** + * Data based on IP CloudPayments request + */ +export interface CardDetails { + /** + * First 6 digits of the card number + */ + CardFirstSix: string; + + /** + * Last 4 digits of the card number + */ + CardLastFour: string; + + /** + * Card payment system: Visa, MasterCard, Maestro or MIR + */ + CardType: CardType; + + /** + * Card expiration date in MM/YY format + */ + CardExpDate: string; + + /** + * Card token for repeated payments without entering details + */ + Token?: string; +} diff --git a/src/billing/types/enums/cardType.ts b/src/billing/types/enums/cardType.ts new file mode 100644 index 00000000..9b275752 --- /dev/null +++ b/src/billing/types/enums/cardType.ts @@ -0,0 +1,10 @@ +/** + * Possible card types + */ +export enum CardType { + VISA = 'Visa', + MASTERCARD = 'Mastercard', + MAESTRO = 'Maestro', + MIR = 'МИР', + UNION_PAY = 'UnionPay' +} diff --git a/src/billing/types/enums/currency.ts b/src/billing/types/enums/currency.ts new file mode 100644 index 00000000..0ceb8566 --- /dev/null +++ b/src/billing/types/enums/currency.ts @@ -0,0 +1,7 @@ +/** + * Payment currency + */ +export enum Currency { + USD = 'USD', + RUB = 'RUB' +} diff --git a/src/billing/types/enums/index.ts b/src/billing/types/enums/index.ts new file mode 100644 index 00000000..6dd92499 --- /dev/null +++ b/src/billing/types/enums/index.ts @@ -0,0 +1,8 @@ +export { Currency } from './currency'; +export { CardType } from './cardType'; +export { OperationType } from './operationType'; +export { OperationStatus } from './operationStatus'; +export { SubscriptionStatus } from './subscriptionStatus'; +export { ReasonCode } from './reasonCode'; +export { Interval } from './interval'; +export { ReasonCodesTranscript } from './reasonCodeTranscript'; diff --git a/src/billing/types/enums/interval.ts b/src/billing/types/enums/interval.ts new file mode 100644 index 00000000..60929ad0 --- /dev/null +++ b/src/billing/types/enums/interval.ts @@ -0,0 +1,8 @@ +/** + * Reccurent payments interval + */ +export enum Interval { + WEEK = 'Week', + MONTH = 'Month', + DAY = 'Day' +} diff --git a/src/billing/types/enums/operationStatus.ts b/src/billing/types/enums/operationStatus.ts new file mode 100644 index 00000000..f86c38e0 --- /dev/null +++ b/src/billing/types/enums/operationStatus.ts @@ -0,0 +1,14 @@ +/** + * Payment status in case of successful completion + */ +export enum OperationStatus { + /** + * Status for one-step payments, + */ + COMPLETED = 'Completed', + + /** + * Status for two-step payments + */ + AUTHORIZED = 'Authorized' +} \ No newline at end of file diff --git a/src/billing/types/enums/operationType.ts b/src/billing/types/enums/operationType.ts new file mode 100644 index 00000000..516285cb --- /dev/null +++ b/src/billing/types/enums/operationType.ts @@ -0,0 +1,19 @@ +/** + * Operation type + */ +export enum OperationType { + /** + * Payment operation + */ + PAYMENT = 'Payment', + + /** + * Refund operation + */ + REFUND = 'Refund', + + /** + * Payout to card + */ + CARD_PAYOUT = 'CardPayout' +} \ No newline at end of file diff --git a/src/billing/types/enums/reasonCode.ts b/src/billing/types/enums/reasonCode.ts new file mode 100644 index 00000000..6847fdeb --- /dev/null +++ b/src/billing/types/enums/reasonCode.ts @@ -0,0 +1,160 @@ +/** + * Transaction rejection code + * https://developers.cloudpayments.ru/#kody-oshibok + */ +export enum ReasonCode { + /** + * Refusal of the issuer to conduct an online transaction + */ + REFER_TO_CARD_ISSUER = 5001, + + /** + * Refusal of the issuer to conduct an online transaction + */ + INVALID_MERCHANT = 5003, + + /** + * Card lost + */ + PICK_UP_CARD = 5004, + + /** + * Refusal of the issuer without explanation + */ + DO_NOT_HONOR = 5005, + + /** + * Network refusal to carry out the operation or incorrect CVV code + */ + ERROR = 5006, + + /** + * Card lost + */ + PICK_UP_CARD_SPECIAL_CONDITIONS = 5007, + + /** + * The card is not available for online payments + */ + INVALID_TRANSACTION = 5012, + + /** + * Too small or too large transaction amount + */ + AMOUNT_ERROR = 5013, + + /** + * Incorrect card number + */ + INVALID_CARD_NUMBER = 5014, + + /** + * Issuer not found + */ + NO_SUCH_ISSUER = 5015, + + /** + * Refusal of the issuer without explanation + */ + TRANSACTION_ERROR = 5019, + + /** + * Error on the acquirer's side - the transaction was incorrectly formed + */ + FORMAT_ERROR = 5030, + + /** + * Unknown card issuer + */ + BANK_NOT_SUPPORTED_BY_SWITCH = 5031, + + /** + * Lost card has expired + */ + EXPIRED_CARD_PICKUP = 5033, + + /** + * Issuer refusal - suspicion of fraud + */ + SUSPECTED_FRAUD = 5034, + + /** + * The card is not intended for payments + */ + RESTRICTED_CARD = 5036, + + /** + * Card lost + */ + LOST_CARD = 5041, + + /** + * Card stolen + */ + STOLEN_CARD = 5043, + + /** + * Insufficient funds + */ + INSUFFICIENT_FUNDS = 5051, + + /** + * The card is expired or the expiration date is incorrect + */ + TRANSACTION_NOT_PERMITTED = 5057, + + /** + * Restriction on the card + */ + RESTRICTED_CARD_2 = 5062, + + /** + * Card blocked due to security breaches + */ + SECURITY_VIOLATION = 5063, + + /** + * The limit of card transactions has been exceeded + */ + EXCEED_WITHDRAWAL_FREQUENCY = 5065, + + /** + * Invalid CVV code + */ + INCORRECT_CVV = 5082, + + /** + * Issuer unavailable + */ + TIMEOUT = 5091, + + /** + * Issuer unavailable + */ + CANNOT_REACH_NETWORK = 5092, + + /** + * Acquiring bank or network error + */ + SYSTEM_ERROR = 5096, + + /** + * The transaction cannot be processed for other reasons + */ + UNABLE_TO_PROCESS = 5204, + + /** + * 3-D Secure authorization failed + */ + AUTHENTICATION_FAILED = 5206, + + /** + * 3-D Secure authorization not available + */ + AUTHENTICATION_UNAVAILABLE = 5207, + + /** + * Acquiring limits for transactions + */ + ANTI_FRAUD = 5300 +} diff --git a/src/billing/types/enums/reasonCodeTranscript.ts b/src/billing/types/enums/reasonCodeTranscript.ts new file mode 100644 index 00000000..bbb92441 --- /dev/null +++ b/src/billing/types/enums/reasonCodeTranscript.ts @@ -0,0 +1,163 @@ +import { ReasonCode } from './reasonCode'; + +/** + * Transcript of transaction rejection code for payment failed event + * Transcript will be used in notification after words "because of" + * https://developers.cloudpayments.ru/#kody-oshibok + */ +export const ReasonCodesTranscript = { + /** + * Refusal of the issuer to conduct an online transaction + */ + [ReasonCode.REFER_TO_CARD_ISSUER]: 'issuer refuse to conduct an online transaction', + + /** + * Refusal of the issuer to conduct an online transaction + */ + [ReasonCode.INVALID_MERCHANT]: 'issuer refuse to conduct an online transaction', + + /** + * Card lost + */ + [ReasonCode.PICK_UP_CARD]: 'the card was lost', + + /** + * Refusal of the issuer without explanation + */ + [ReasonCode.DO_NOT_HONOR]: 'error on the payment service side', + + /** + * Network refusal to carry out the operation or incorrect CVV code + */ + [ReasonCode.ERROR]: 'network refusal to carry out the operation or incorrect CVV code', + + /** + * Card lost + */ + [ReasonCode.PICK_UP_CARD_SPECIAL_CONDITIONS]: 'the card was lost', + + /** + * The card is not available for online payments + */ + [ReasonCode.INVALID_TRANSACTION]: 'the card is not available for online payments', + + /** + * Too small or too large transaction amount + */ + [ReasonCode.AMOUNT_ERROR]: 'too small or too large transaction amount', + + /** + * Incorrect card number + */ + [ReasonCode.INVALID_CARD_NUMBER]: 'incorrect card number', + + /** + * Issuer not found + */ + [ReasonCode.NO_SUCH_ISSUER]: 'issuer not found', + + /** + * Refusal of the issuer without explanation + */ + [ReasonCode.TRANSACTION_ERROR]: 'error on the payment service side', + + /** + * Error on the acquirer's side - the transaction was incorrectly formed + */ + [ReasonCode.FORMAT_ERROR]: 'error on the payment service side', + + /** + * Unknown card issuer + */ + [ReasonCode.BANK_NOT_SUPPORTED_BY_SWITCH]: 'unknown card issuer', + + /** + * Lost card has expired + */ + [ReasonCode.EXPIRED_CARD_PICKUP]: 'the card has expired', + + /** + * Issuer refusal - suspicion of fraud + */ + [ReasonCode.SUSPECTED_FRAUD]: 'error on the payment service side', + + /** + * The card is not intended for payments + */ + [ReasonCode.RESTRICTED_CARD]: 'the card is not intended for payments', + + /** + * Card lost + */ + [ReasonCode.LOST_CARD]: 'error on the payment service side', + + /** + * Card stolen + */ + [ReasonCode.STOLEN_CARD]: 'error on the payment service side', + + /** + * Insufficient funds + */ + [ReasonCode.INSUFFICIENT_FUNDS]: 'insufficient funds', + + /** + * The card is expired or the expiration date is incorrect + */ + [ReasonCode.TRANSACTION_NOT_PERMITTED]: 'the card has expired or the expiration date is incorrect', + + /** + * Restriction on the card + */ + [ReasonCode.RESTRICTED_CARD_2]: 'restriction on the card', + + /** + * Card blocked due to security breaches + */ + [ReasonCode.SECURITY_VIOLATION]: 'the card blocked due to security breaches', + + /** + * The limit of card transactions has been exceeded + */ + [ReasonCode.EXCEED_WITHDRAWAL_FREQUENCY]: 'the limit of card transactions has been exceeded', + + /** + * Invalid CVV code + */ + [ReasonCode.INCORRECT_CVV]: 'invalid CVV code', + + /** + * Issuer unavailable + */ + [ReasonCode.TIMEOUT]: 'issuer unavailable', + + /** + * Issuer unavailable + */ + [ReasonCode.CANNOT_REACH_NETWORK]: 'issuer unavailable', + + /** + * Acquiring bank or network error + */ + [ReasonCode.SYSTEM_ERROR]: 'error on the payment service side', + + /** + * The transaction cannot be processed for other reasons + */ + [ReasonCode.UNABLE_TO_PROCESS]: 'error on the payment service side', + + /** + * 3-D Secure authorization failed + */ + [ReasonCode.AUTHENTICATION_FAILED]: '3-D Secure authorization failed', + + /** + * 3-D Secure authorization not available + */ + [ReasonCode.AUTHENTICATION_UNAVAILABLE]: '3-D Secure authorization not available', + + /** + * Acquiring limits for transactions + */ + [ReasonCode.ANTI_FRAUD]: 'acquiring limits for transactions', +}; diff --git a/src/billing/types/enums/subscriptionStatus.ts b/src/billing/types/enums/subscriptionStatus.ts new file mode 100644 index 00000000..2422bf7e --- /dev/null +++ b/src/billing/types/enums/subscriptionStatus.ts @@ -0,0 +1,34 @@ +/** + * Possible subscription status + */ +export enum SubscriptionStatus { + /** + * Subscription active. + * After creation and next successful payment + */ + ACTIVE = 'Active', + + /** + * Subscription expired. + * After one or two consecutive unsuccessful payment attempts + */ + PASTDUE = 'PastDue', + + /** + * Subscription cancelled. + * In case of cancellation upon request + */ + CANCELLED = 'Cancelled', + + /** + * Subscription rejected. + * In case of three unsuccessful payment attempts in a row + */ + REJECTED = 'Rejected', + + /** + * Subscription expired. + * In case of completion of the maximum number of periods (if specified) + */ + EXPIRED = 'Expired' +} diff --git a/src/billing/types/index.ts b/src/billing/types/index.ts new file mode 100644 index 00000000..cad8cea0 --- /dev/null +++ b/src/billing/types/index.ts @@ -0,0 +1,2 @@ +export { CheckCodes, CheckResponse, PayCodes, PayResponse, FailCodes, FailResponse, RecurrentCodes, RecurrentResponse } from './response'; +export { CheckRequest, PayRequest, FailRequest, RecurrentRequest } from './request'; diff --git a/src/billing/types/ipData.ts b/src/billing/types/ipData.ts new file mode 100644 index 00000000..47eff304 --- /dev/null +++ b/src/billing/types/ipData.ts @@ -0,0 +1,29 @@ +/** + * Data based on IP CloudPayments request + */ +export interface IpData { + /** + * Payer's IP address + */ + IpAdress?: string; + + /** + * ISO3166-1 two-letter country code of the payer's country + */ + IpCountry?: string; + + /** + * Payer's city + */ + IpCity?: string; + + /** + * Payer's region + */ + IpRegion?: string; + + /** + * Payer's district + */ + IpDistrict?: string; +} diff --git a/src/billing/types/paymentData.ts b/src/billing/types/paymentData.ts new file mode 100644 index 00000000..9ca61185 --- /dev/null +++ b/src/billing/types/paymentData.ts @@ -0,0 +1,43 @@ +/** + * Data for setting up recurring payments + */ +interface RecurrentPaymentSettings { + /** + * Payment interval + */ + interval: 'Day' | 'Week' | 'Month'; + + /** + * Payment period. That is, how often to withdraw money + */ + period: number; + + /** + * Subscription start date (first payment) + */ + startDate?: string; + + /** + * Recurring payment amount. + */ + amount?: number; +} + +/** + * Data for the needs of Cloudpayments + */ +interface CloudPaymentsSettings { + /** + * Data for recurrent payments + * + * @see https://developers.cloudpayments.ru/#rekurrentnye-platezhi-podpiska + */ + recurrent: RecurrentPaymentSettings; +} + +export interface PaymentData { + /** + * Data for Cloudpayments needs + */ + cloudPayments?: CloudPaymentsSettings; +} diff --git a/src/billing/types/request.ts b/src/billing/types/request.ts new file mode 100644 index 00000000..93039d75 --- /dev/null +++ b/src/billing/types/request.ts @@ -0,0 +1,421 @@ +import { Currency, OperationType, OperationStatus, SubscriptionStatus, ReasonCode, Interval } from './enums'; +import { CardDetails } from './cardDetails'; +import { IpData } from './ipData'; +import { PaymentData } from './paymentData'; + +/** + * Check request body + * https://developers.cloudpayments.ru/#check + */ +export interface CheckRequest extends CardDetails, IpData { + /** + * Number of transaction in the system + */ + TransactionId: number; + + /** + * Payment amount from the payment parameters + */ + Amount: string; + + /** + * Currency: RUB/USD + */ + Currency: Currency; + + /** + * Date/time of payment creation in UTC time zone + */ + DateTime: Date; + + /** + * Test mode sign + */ + TestMode: boolean; + + /** + * Payment status in case of successful completion: + * Completed - for one-step payments, + * Authorized - for two-step payments + */ + Status: OperationStatus; + + /** + * Operation type: Payment/Refund/CardPayout + */ + OperationType: OperationType; + + /** + * Order number from payment parameters + */ + InvoiceId?: string; + + /** + * User ID from payment parameters + */ + AccountId?: string; + + /** + * Subscription ID (for recurring payments) + */ + SubscriptionId?: string; + + /** + * Payee token + */ + TokenRecipient?: string; + + /** + * Cardholder name + */ + Name?: string; + + /** + * Payer's e-mail address + */ + Email?: string; + + /** + * Name of the card issuing bank + */ + Issuer: string; + + /** + * ISO3166-1 two-letter country code of the card issuer + */ + IssuerBankCountry?: string; + + /** + * Payment purpose from payment parameters + */ + Description?: string; + + /** + * An arbitrary set of parameters passed to the transaction + */ + Data?: string; +} + +/** + * Pay request body + * https://developers.cloudpayments.ru/#pay + */ +export interface PayRequest extends CardDetails, IpData { + /** + * Number of transaction in the system + */ + TransactionId: number; + + /** + * Payment amount from the payment parameters + */ + Amount: string; + + /** + * Currency: RUB/USD + */ + Currency: Currency; + + /** + * Date/time of payment creation in UTC time zone + */ + DateTime: Date; + + /** + * Test mode sign + */ + TestMode: boolean; + + /** + * Payment status in case of successful completion: + * Completed - for one-step payments, + * Authorized - for two-step payments + */ + Status: OperationStatus; + + /** + * Operation type: Payment/CardPayout + */ + OperationType: Exclude; + + /** + * Acquiring bank identifier + */ + GatewayName: string; + + /** + * Order number from payment parameters + */ + InvoiceId?: string; + + /** + * User ID from payment parameters + */ + AccountId?: string; + + /** + * Subscription ID (for recurring payments) + */ + SubscriptionId?: string; + + /** + * Payee token + */ + TokenRecipient?: string; + + /** + * Cardholder name + */ + Name?: string; + + /** + * Payer's e-mail address + */ + Email?: string; + + /** + * Name of the card issuing bank + */ + Issuer?: string; + + /** + * ISO3166-1 two-letter country code of the card issuer + */ + IssuerBankCountry?: string; + + /** + * Payment purpose from payment parameters + */ + Description?: string; + + /** + * An arbitrary set of parameters passed to the transaction + */ + Data?: string; + + /** + * Total commission value + */ + TotalFee: number; + + /** + * Card product type + */ + CardProduct?: string; + + /** + * Payment method ApplePay or GooglePay + */ + PaymentMethod?: string; + + /** + * First unsuccessful transaction number + */ + FallBackScenarioDeclinedTransactionId?: number; +} + +/** + * Fail request body + * https://developers.cloudpayments.ru/#fail + */ +export interface FailRequest extends CardDetails, IpData { + /** + * Number of transaction in the system + */ + TransactionId: number; + + /** + * Payment amount from the payment parameters + */ + Amount: number; + + /** + * Currency: RUB/USD + */ + Currency: Currency; + + /** + * Date/time of payment creation in UTC time zone + */ + DateTime: Date; + + /** + * Test mode sign + */ + TestMode: boolean; + + /** + * Rejection reason + */ + Reason: string; + + /** + * Error code + * https://developers.cloudpayments.ru/#kody-oshibok + */ + ReasonCode: ReasonCode; + + /** + * Operation type: Payment/Refund/CardPayout + */ + OperationType: OperationType; + + /** + * Order number from payment parameters + */ + InvoiceId?: string; + + /** + * User ID from payment parameters + */ + AccountId?: string; + + /** + * Subscription ID (for recurring payments) + */ + SubscriptionId?: string; + + /** + * Payee token + */ + TokenRecipient?: string; + + /** + * Cardholder name + */ + Name?: string; + + /** + * Payer's e-mail address + */ + Email?: string; + + /** + * Name of the card issuing bank + */ + Issuer: string; + + /** + * ISO3166-1 two-letter country code of the card issuer + */ + IssuerBankCountry?: string; + + /** + * Payment purpose from payment parameters + */ + Description?: string; + + /** + * An arbitrary set of parameters passed to the transaction + */ + Data?: string; + + /** + * Payment method ApplePay or GooglePay + */ + PaymentMethod?: string; + + /** + * First unsuccessful transaction number + */ + FallBackScenarioDeclinedTransactionId?: number; +} + +/** + * Reccurrent request body + * https://developers.cloudpayments.ru/#recurrent + */ +export interface RecurrentRequest { + /** + * Subscription ID + */ + Id: string; + + /** + * User ID + */ + AccountId: string; + + /** + * Free form payment purpose + */ + Description: string; + + /** + * Payer's e-mail + */ + Email: string; + + /** + * Amount of payment + */ + Amount: string; + + /** + * Currency: RUB/USD + */ + Currency: Currency; + + /** + * If the value is true - the payment will be performed according to a two-stage scheme + */ + RequireConfirmation: boolean; + + /** + * Date and time of the first payment according to the plan in the UTC time zone + */ + StartDate: string; + + /** + * Interval. Possible values: Week, Month, Day + */ + Interval: Interval; + + /** + * Period. In combination with the interval, + * 1 Month means once a month, and 2 Week means once every two weeks. + */ + Period: number; + + /** + * Subscription statuses + * https://developers.cloudpayments.ru/#statusy-podpisok-rekurrent + */ + Status: SubscriptionStatus; + + /** + * Number of successful payments + */ + SuccessfulTransactionsNumber: number; + + /** + * The number of unsuccessful payments + * (reset to zero after each successful one) + */ + FailedTransactionsNumber: number; + + /** + * Maximum number of payments in a subscription + */ + MaxPeriods?: number; + + /** + * Date and time of the last successful payment in the UTC time zone + */ + LastTransactionDate?: string; + + /** + * Date and time of the next payment in the UTC time zone + */ + NextTransactionDate?: string; +} + +/** + * Data that we expect in the request + */ +export interface WebhookData extends PaymentData { + /** + * Checksum for validating request and getting data from it + */ + checksum: string; +} diff --git a/src/billing/types/response.ts b/src/billing/types/response.ts new file mode 100644 index 00000000..f5da282c --- /dev/null +++ b/src/billing/types/response.ts @@ -0,0 +1,102 @@ +/** + * Basic response to cloud payments + */ +export interface CPResponse { + /** + * Code of response + */ + code: number; +} + +/** + * Response for the check route + */ +export interface CheckResponse extends CPResponse { + code: CheckCodes; +} + +/** + * Response for the pay route + */ +export interface PayResponse extends CPResponse { + code: PayCodes; +} + +/** + * Response for the fail route + */ +export interface FailResponse extends CPResponse { + code: FailCodes; +} + +/** + * Response for the recurrent route + */ +export interface RecurrentResponse extends CPResponse { + code: RecurrentCodes; +} + +/** + * Codes of check route response + */ +export enum CheckCodes { + /** + * Payment can be made + */ + SUCCESS = 0, + + /** + * Invalid invoice number + */ + INVALID_INVOICE_ID = 10, + + /** + * Incorrect AccountId + */ + INCORRECT_ACCOUNT_ID = 11, + + /** + * Wrong payment amount + */ + WRONG_AMOUNT = 12, + + /** + * Any other reason for refusal + */ + PAYMENT_COULD_NOT_BE_ACCEPTED = 13, + + /** + * Payment is overdue + */ + PAYMENT_IS_OVERDUE = 20 +} + +/** + * Codes of pay route response + */ +export enum PayCodes { + /** + * Payment registered + */ + SUCCESS = 0, +} + +/** + * Codes of fail route response + */ +export enum FailCodes { + /** + * Attempt registered + */ + SUCCESS = 0, +} + +/** + * Codes of reccurrent route response + */ +export enum RecurrentCodes { + /** + * Changes registered + */ + SUCCESS = 0, +} diff --git a/src/directives/defaultValue.ts b/src/directives/defaultValue.ts index 224eac6a..9a623aa5 100644 --- a/src/directives/defaultValue.ts +++ b/src/directives/defaultValue.ts @@ -1,10 +1,10 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' -import {UnknownGraphQLResolverResult} from "../types/graphql"; +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { UnknownGraphQLResolverResult } from '../types/graphql'; export default function defaultValueDirective(directiveName = 'default') { return { - defaultValueDirectiveTypeDefs:` + defaultValueDirectiveTypeDefs: ` """ Directive for setting field default value """ @@ -20,6 +20,7 @@ export default function defaultValueDirective(directiveName = 'default') { if (defaultValueDirective) { let { value } = defaultValueDirective as {value: string}; + try { value = JSON.parse(value); } catch (_) { @@ -38,8 +39,9 @@ export default function defaultValueDirective(directiveName = 'default') { return result; }; } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/renameFrom.ts b/src/directives/renameFrom.ts index 082f0c2f..0d6a79ab 100644 --- a/src/directives/renameFrom.ts +++ b/src/directives/renameFrom.ts @@ -1,9 +1,9 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; export default function renameFromDirective(directiveName = 'renameFrom') { return { - renameFromDirectiveTypeDefs:` + renameFromDirectiveTypeDefs: ` """ Directive for field renaming """ @@ -21,14 +21,16 @@ export default function renameFromDirective(directiveName = 'renameFrom') { const { name } = renameFromDirective as {name: string}; const { resolve = defaultFieldResolver } = fieldConfig; + fieldConfig.resolve = (parent, args, context, info) => { parent[fieldName] = parent[name]; return resolve(parent, args, context, info); - } + }; } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/requireAdmin.ts b/src/directives/requireAdmin.ts index a4b1d6d7..453b161b 100644 --- a/src/directives/requireAdmin.ts +++ b/src/directives/requireAdmin.ts @@ -1,8 +1,8 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' -import {ResolverContextWithUser, UnknownGraphQLResolverResult} from "../types/graphql"; -import {ForbiddenError, UserInputError} from "apollo-server-express"; -import WorkspaceModel from "../models/workspace"; +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { ResolverContextWithUser, UnknownGraphQLResolverResult } from '../types/graphql'; +import { ForbiddenError, UserInputError } from 'apollo-server-express'; +import WorkspaceModel from '../models/workspace'; /** * Check is user admin via workspace id @@ -87,8 +87,9 @@ export default function requireAdminDirective(directiveName = 'requireAdmin') { return resolve(...resolverArgs); }; } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/requireAuth.ts b/src/directives/requireAuth.ts index 313c35b3..d5e903a0 100644 --- a/src/directives/requireAuth.ts +++ b/src/directives/requireAuth.ts @@ -1,8 +1,8 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' -import {ResolverContextBase, UnknownGraphQLResolverResult} from "../types/graphql"; -import {AccessTokenExpiredError} from "../errors"; -import {AuthenticationError} from "apollo-server-express"; +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { ResolverContextBase, UnknownGraphQLResolverResult } from '../types/graphql'; +import { AccessTokenExpiredError } from '../errors'; +import { AuthenticationError } from 'apollo-server-express'; /** * Authorizes the user or throws an error if the data is incorrect @@ -20,7 +20,6 @@ function checkUser(context: ResolverContextBase): void { } } - export default function requireAuthDirective(directiveName = 'requireAuth') { return { requireAuthDirectiveTypeDefs: ` @@ -51,8 +50,9 @@ export default function requireAuthDirective(directiveName = 'requireAuth') { return resolve.apply(this, resolverArgs); }; } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/requireUserInWorkspace.ts b/src/directives/requireUserInWorkspace.ts index ea344332..092b651b 100644 --- a/src/directives/requireUserInWorkspace.ts +++ b/src/directives/requireUserInWorkspace.ts @@ -1,7 +1,7 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' -import {ResolverContextBase, UnknownGraphQLResolverResult} from "../types/graphql"; -import {ForbiddenError} from "apollo-server-express"; +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { ResolverContextBase, UnknownGraphQLResolverResult } from '../types/graphql'; +import { ForbiddenError } from 'apollo-server-express'; /** * Throw error from sync function @@ -59,10 +59,9 @@ async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, pro } } - export default function requireUserInWorkspaceDirective(directiveName = 'requireUserInWorkspace') { return { - requireUserInWorkspaceDirectiveTypeDefs:` + requireUserInWorkspaceDirectiveTypeDefs: ` """ Directive for checking user in workspace """ @@ -97,8 +96,9 @@ export default function requireUserInWorkspaceDirective(directiveName = 'require return resolve.apply(this, resolverArgs); }; } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/uploadImageDirective.ts b/src/directives/uploadImageDirective.ts index 6715b4a3..dafd5236 100644 --- a/src/directives/uploadImageDirective.ts +++ b/src/directives/uploadImageDirective.ts @@ -1,7 +1,6 @@ -import {defaultFieldResolver, GraphQLSchema, InputValueDefinitionNode} from "graphql"; -import {mapSchema, MapperKind, getDirective} from '@graphql-tools/utils' -import {BooleanValueNode} from "graphql/language/ast"; -import {save} from "../utils/files"; +import { defaultFieldResolver, GraphQLSchema, InputValueDefinitionNode } from 'graphql'; +import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils'; +import { save } from '../utils/files'; export default function uploadImageDirective(directiveName = 'uploadImage') { return { @@ -15,26 +14,31 @@ export default function uploadImageDirective(directiveName = 'uploadImage') { mapSchema(schema, { [MapperKind.MUTATION_ROOT_FIELD]: (fieldConfig) => { const fieldArgs = fieldConfig.astNode?.arguments; + if (fieldArgs) { fieldArgs.forEach(arg => { const directives = arg.directives; + directives?.forEach(directive => { if (directive.name.value === directiveName) { - const {resolve = defaultFieldResolver} = fieldConfig; + const { resolve = defaultFieldResolver } = fieldConfig; + fieldConfig.resolve = async (object, args, context, info) => { if (args[arg.name.value]) { const imageMeta = await (args[arg.name.value] as Promise); args[arg.name.value] = await save(imageMeta.file.createReadStream(), imageMeta.mimetype); } + return resolve(object, args, context, info); }; } - }) - }) + }); + }); } + return fieldConfig; - } - }) - } + }, + }), + }; } diff --git a/src/directives/validate.ts b/src/directives/validate.ts index 473a7aad..7dce39ee 100644 --- a/src/directives/validate.ts +++ b/src/directives/validate.ts @@ -1,7 +1,7 @@ -import {defaultFieldResolver, GraphQLSchema} from "graphql"; -import {mapSchema, MapperKind, getDirective, getDirectives} from '@graphql-tools/utils' -import {UserInputError} from "apollo-server-express"; -import {BooleanValueNode} from "graphql/language/ast"; +import { defaultFieldResolver, GraphQLSchema } from 'graphql'; +import { mapSchema, MapperKind, getDirective, getDirectives } from '@graphql-tools/utils'; +import { UserInputError } from 'apollo-server-express'; +import { BooleanValueNode } from 'graphql/language/ast'; /** * Validates string using regex @@ -12,8 +12,8 @@ function checkEmail(email: string): void { const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/g; if (email.match(emailRegex) === null) { - throw new UserInputError('Wrong email format'); -} + throw new UserInputError('Wrong email format'); + } } /** @@ -22,13 +22,21 @@ function checkEmail(email: string): void { */ function checkNotEmpty(str: string): void { if (str.replace(/\s/g, '').length == 0) { - throw new UserInputError('The value must not be empty'); -} + throw new UserInputError('The value must not be empty'); + } } -export default function validateDirective(directiveName = 'validate') { +/** + * + * @param directiveName + * @returns + */ +export default function validateDirective(directiveName = 'validate'): { + validateDirectiveTypeDefs: string; + validateDirectiveTransformer: (schema: GraphQLSchema) => GraphQLSchema; +} { return { - validateDirectiveTypeDefs:` + validateDirectiveTypeDefs: ` """ Directive for checking a field for empty space """ @@ -38,16 +46,20 @@ export default function validateDirective(directiveName = 'validate') { mapSchema(schema, { [MapperKind.MUTATION_ROOT_FIELD]: (fieldConfig, fieldName) => { const args = fieldConfig.astNode?.arguments; + if (args) { args.forEach(arg => { const directives = arg.directives; + directives?.forEach(directive => { if (directive.name.value === directiveName) { const directiveArguments = directive.arguments; const isEmail = (directiveArguments?.find(arg => arg.name.value === 'isEmail')?.value as BooleanValueNode)?.value; const notEmpty = (directiveArguments?.find(arg => arg.name.value === 'notEmpty')?.value as BooleanValueNode)?.value; + if (isEmail || notEmpty) { const { resolve = defaultFieldResolver } = fieldConfig; + fieldConfig.resolve = async (object, args, context, info) => { if (isEmail) { checkEmail(args[arg.name.value] || ''); @@ -55,15 +67,17 @@ export default function validateDirective(directiveName = 'validate') { if (notEmpty) { checkNotEmpty(args[arg.name.value] || ''); } + return resolve(object, args, context, info); }; } } - }) - }) + }); + }); } - return fieldConfig - } - }) - } + + return fieldConfig; + }, + }), + }; } diff --git a/src/index.ts b/src/index.ts index ba8266d7..1ed92aa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,12 @@ import { GraphQLError } from 'graphql'; import WorkspacesFactory from './models/workspacesFactory'; import DataLoaders from './dataLoaders'; import HawkCatcher from '@hawk.so/nodejs'; +// import { express as voyagerMiddleware } from 'graphql-voyager/middleware'; +/* + * @ts-ignore + * import Accounting from 'codex-accounting-sdk'; + */ +import Billing from './billing'; import bodyParser from 'body-parser'; import { ApolloServerPluginLandingPageGraphQLPlayground, ApolloServerPluginLandingPageDisabled } from 'apollo-server-core'; import ProjectsFactory from './models/projectsFactory'; @@ -19,7 +25,7 @@ import { NonCriticalError } from './errors'; import PlansFactory from './models/plansFactory'; import BusinessOperationsFactory from './models/businessOperationsFactory'; import schema from './schema'; -import {graphqlUploadExpress} from 'graphql-upload' +import { graphqlUploadExpress } from 'graphql-upload'; /** * Option to enable playground @@ -83,6 +89,10 @@ class HawkAPI { next(); }); + const billing = new Billing(); + + billing.appendRoutes(this.app); + this.server = new ApolloServer({ schema, debug: process.env.NODE_ENV === 'development', @@ -176,12 +186,39 @@ class HawkAPI { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const dataLoader = new DataLoaders(mongo.databases.hawk!); + /** + * Initializing accounting SDK + */ + let tlsVerify; + + /** + * Checking env variables + * If at least one path is not transmitted, the variable tlsVerify is undefined + */ + if ( + ![process.env.TLS_CA_CERT, process.env.TLS_CERT, process.env.TLS_KEY].some(value => value === undefined || value.length === 0) + ) { + tlsVerify = { + tlsCaCertPath: `${process.env.TLS_CA_CERT}`, + tlsCertPath: `${process.env.TLS_CERT}`, + tlsKeyPath: `${process.env.TLS_KEY}`, + }; + } + + /* + * const accounting = new Accounting({ + * baseURL: `${process.env.CODEX_ACCOUNTING_URL}`, + * tlsVerify, + * }); + */ + return { factories: HawkAPI.setupFactories(dataLoader), user: { id: userId, accessTokenExpired: isAccessTokenExpired, }, + // accounting, }; } diff --git a/src/models/plan.ts b/src/models/plan.ts index a9a9de06..bdfa7945 100644 --- a/src/models/plan.ts +++ b/src/models/plan.ts @@ -17,10 +17,15 @@ export default class PlanModel extends AbstractModel implements Pl public name!: string; /** - * Monthly charge for plan in dollars + * Monthly charge for plan in currencry specified in `monthlyChargeCurrency` */ public monthlyCharge!: number; + /** + * Currency of `monthlyCharge` + */ + public monthlyChargeCurrency!: string; + /** * Maximum amount of events available for plan */ diff --git a/src/models/usersFactory.ts b/src/models/usersFactory.ts index 75a7ee92..ffa7f249 100644 --- a/src/models/usersFactory.ts +++ b/src/models/usersFactory.ts @@ -82,7 +82,9 @@ export default class UsersFactory extends AbstractModelFactory { - return channel.endpoint.replace(/\s+/, '').trim().length !== 0; + .filter(([_, channel]) => { + return (channel as NotificationsChannelSettingsDBScheme).endpoint.replace(/\s+/, '').trim().length !== 0; }); return notEmptyChannels.length === 0; diff --git a/src/resolvers/user.ts b/src/resolvers/user.ts index e280fbae..bfe663c5 100644 --- a/src/resolvers/user.ts +++ b/src/resolvers/user.ts @@ -10,7 +10,7 @@ import isE2E from '../utils/isE2E'; import { dateFromObjectId } from '../utils/dates'; import { UserDBScheme } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; -import {MongoError} from "mongodb"; +import { MongoError } from 'mongodb'; /** * See all types and fields here {@see ../typeDefs/user.graphql} diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index bcbfad6a..bddb5955 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -8,6 +8,7 @@ import { SenderWorkerTaskType } from '../types/userNotifications'; import ProjectToWorkspace from '../models/projectToWorkspace'; import Validator from '../utils/validator'; import { dateFromObjectId } from '../utils/dates'; +import cloudPaymentsApi from '../utils/cloudPaymentsApi'; const { ApolloError, UserInputError, ForbiddenError } = require('apollo-server-express'); const crypto = require('crypto'); @@ -41,11 +42,23 @@ module.exports = { * @param {string} image - workspace image * @param {UserInContext} user - current authorized user {@see ../index.js} * @param {ContextFactories} factories - factories for working with models + * @param {Accounting} accounting - SDK for creating account for new workspace * * @return {WorkspaceModel} created workspace */ - async createWorkspace(_obj, { name, description, image }, { user, factories }) { + async createWorkspace(_obj, { name, description, image }, { user, factories, accounting }) { try { + /* + * Create workspace account and set account id to workspace + * const accountResponse = await accounting.createAccount({ + * name: 'WORKSPACE:' + name, + * type: AccountType.LIABILITY, + * currency: Currency.RUB, + * }); + */ + + // const accountId = accountResponse.recordId; + const accountId = null; /** * @type {WorkspaceDBScheme} @@ -54,7 +67,7 @@ module.exports = { name, description, image, - accountId: '0', + accountId, }; const ownerModel = await factories.usersFactory.findById(user.id); @@ -537,6 +550,8 @@ module.exports = { await cloudPaymentsApi.cancelSubscription(workspaceModel.subscriptionId); + await workspaceModel.setSubscriptionId(null); + return { recordId: workspaceModel._id, record: { diff --git a/src/schema.ts b/src/schema.ts index d2c0320d..faa679bf 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,24 +1,22 @@ import resolvers from './resolvers'; import typeDefs from './typeDefs'; -import {makeExecutableSchema} from "@graphql-tools/schema"; -import renameFromDirective from "./directives/renameFrom"; -import { mergeTypeDefs } from '@graphql-tools/merge' -import defaultValueDirective from "./directives/defaultValue"; -import validateDirective from "./directives/validate"; -import uploadImageDirective from "./directives/uploadImageDirective"; -import requireAuthDirective from "./directives/requireAuth"; -import requireAdminDirective from "./directives/requireAdmin"; -import requireUserInWorkspaceDirective from "./directives/requireUserInWorkspace"; - - -const { renameFromDirectiveTypeDefs, renameFromDirectiveTransformer } = renameFromDirective() -const { defaultValueDirectiveTypeDefs, defaultValueDirectiveTransformer } = defaultValueDirective() -const { validateDirectiveTypeDefs, validateDirectiveTransformer } = validateDirective() -const { uploadImageDirectiveTypeDefs, uploadImageDirectiveTransformer } = uploadImageDirective() -const { requireAuthDirectiveTypeDefs, requireAuthDirectiveTransformer } = requireAuthDirective() -const { requireAdminDirectiveTypeDefs, requireAdminDirectiveTransformer } = requireAdminDirective() -const { requireUserInWorkspaceDirectiveTypeDefs, requireUserInWorkspaceDirectiveTransformer } = requireUserInWorkspaceDirective() +import { makeExecutableSchema } from '@graphql-tools/schema'; +import renameFromDirective from './directives/renameFrom'; +import { mergeTypeDefs } from '@graphql-tools/merge'; +import defaultValueDirective from './directives/defaultValue'; +import validateDirective from './directives/validate'; +import uploadImageDirective from './directives/uploadImageDirective'; +import requireAuthDirective from './directives/requireAuth'; +import requireAdminDirective from './directives/requireAdmin'; +import requireUserInWorkspaceDirective from './directives/requireUserInWorkspace'; +const { renameFromDirectiveTypeDefs, renameFromDirectiveTransformer } = renameFromDirective(); +const { defaultValueDirectiveTypeDefs, defaultValueDirectiveTransformer } = defaultValueDirective(); +const { validateDirectiveTypeDefs, validateDirectiveTransformer } = validateDirective(); +const { uploadImageDirectiveTypeDefs, uploadImageDirectiveTransformer } = uploadImageDirective(); +const { requireAuthDirectiveTypeDefs, requireAuthDirectiveTransformer } = requireAuthDirective(); +const { requireAdminDirectiveTypeDefs, requireAdminDirectiveTransformer } = requireAdminDirective(); +const { requireUserInWorkspaceDirectiveTypeDefs, requireUserInWorkspaceDirectiveTransformer } = requireUserInWorkspaceDirective(); let schema = makeExecutableSchema({ typeDefs: mergeTypeDefs([ @@ -29,10 +27,10 @@ let schema = makeExecutableSchema({ requireAuthDirectiveTypeDefs, requireAdminDirectiveTypeDefs, requireUserInWorkspaceDirectiveTypeDefs, - ...typeDefs + ...typeDefs, ]), resolvers, -}) +}); schema = renameFromDirectiveTransformer(schema); schema = defaultValueDirectiveTransformer(schema); diff --git a/src/typeDefs/billing.ts b/src/typeDefs/billing.ts index 6ebf1abc..807821f3 100644 --- a/src/typeDefs/billing.ts +++ b/src/typeDefs/billing.ts @@ -121,9 +121,14 @@ type PayloadOfWorkspacePlanPurchase { workspace: Workspace! """ - Amount of payment in US cents + 1/100 of the final amount. (US cents for USD, kopecks for RUB) """ amount: Long! + + """ + Currency of payment + """ + currency: String! } """ diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index fff0094e..f84d7105 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -394,6 +394,13 @@ type DailyEventInfo { lastRepetitionTime: Float! } +type Subscription { + """ + Sends new events from all user projects + """ + eventOccurred: Event! @requireAuth +} + """ Event information per day with these events """ diff --git a/src/typeDefs/plans.ts b/src/typeDefs/plans.ts index a50eb6cf..7da8191e 100644 --- a/src/typeDefs/plans.ts +++ b/src/typeDefs/plans.ts @@ -20,6 +20,11 @@ export default gql` """ monthlyCharge: Int! + """ + Currency of monthlyCharge + """ + monthlyChargeCurrency: String! + """ Events limit for plan """ diff --git a/src/types/graphql.ts b/src/types/graphql.ts index 772aff43..d3ee4095 100644 --- a/src/types/graphql.ts +++ b/src/types/graphql.ts @@ -2,6 +2,7 @@ import UsersFactory from '../models/usersFactory'; import WorkspacesFactory from '../models/workspacesFactory'; import { GraphQLField } from 'graphql'; import ProjectsFactory from '../models/projectsFactory'; +// import Accounting from 'codex-accounting-sdk'; import PlansFactory from '../models/plansFactory'; import BusinessOperationsFactory from '../models/businessOperationsFactory'; @@ -18,6 +19,11 @@ export interface ResolverContextBase { * Factories for working with models */ factories: ContextFactories; + + // /** + // * SDK for working with CodeX Accounting API + // */ + // accounting: Accounting; } /** diff --git a/src/utils/cloudPaymentsApi.ts b/src/utils/cloudPaymentsApi.ts new file mode 100644 index 00000000..b489586e --- /dev/null +++ b/src/utils/cloudPaymentsApi.ts @@ -0,0 +1,192 @@ +import axios, { AxiosInstance } from 'axios'; + +/** + * Settings for CloudPayments API + */ +interface CloudPaymentsApiSettings { + /** + * Public id for the site + */ + publicId: string; + + /** + * Site's secret + */ + secret: string; +} + +/** + * Data for setting up recurrent payments + */ +interface RecurrentPaymentData { + /** + * Payment interval + */ + interval: 'Day' | 'Week' | 'Month'; + + /** + * Payment period. That is, how often to withdraw money + */ + period: number; + + /** + * Subscription start date (first payment) + */ + startDate?: string; + + /** + * Recurring payment amount. + */ + amount?: number; +} + +/** + * Data for CloudPayments internal purposes + */ +interface CloudPaymentsData { + /** + * Data for recurrent payments + * + * @see https://developers.cloudpayments.ru/#rekurrentnye-platezhi-podpiska + */ + recurrent: RecurrentPaymentData; +} + +/** + * Data to be sent with pay event to and back from payments server + */ +export interface CloudPaymentsJsonData { + /** + * Hash to check data + */ + checksum: string; + + /** + * Data for Cloudpayments needs + */ + cloudPayments?: CloudPaymentsData; +} + +/** + * Payload of the API method to process payment via token + */ +interface PayWithTokenPayload { + /** + * Payment amount + */ + Amount: number; + + /** + * User ID from payment parameters + */ + AccountId: string; + + /** + * Card token for processing payment + */ + Token: string; + + /** + * Other data for request + */ + JsonData: CloudPaymentsJsonData; + + /** + * Currency: RUB/USD + */ + Currency: string; +} + +/** + * Response of the API method to process payment via token + */ +interface PayWithCardResponse { + /** + * Operation status + */ + Success: boolean; + Model: { + /** + * Id of the transaction + */ + TransactionId: number; + }; +} + +/** + * Class for interacting with CloudPayments API + * @see https://developers.cloudpayments.ru/#api + */ +class CloudPaymentsApi { + /** + * CloudPayments public id + */ + private readonly publicId: string; + + /** + * CloudPayments API secret + */ + private readonly secret: string; + + /** + * Axios instance to make calls to API + */ + private readonly api: AxiosInstance; + + /** + * Creates class instance + * @param settings - settings for class initialization + */ + constructor(settings: CloudPaymentsApiSettings) { + this.publicId = settings.publicId; + this.secret = settings.secret; + + this.api = axios.create({ + baseURL: 'https://api.cloudpayments.ru/', + auth: { + username: this.publicId, + password: this.secret, + }, + }); + } + + /** + * Cancel subscription by its id + * + * @param subscriptionId - subscription id to cancel + */ + public async cancelSubscription(subscriptionId: string): Promise { + await this.api.post('/subscriptions/cancel', { + Id: subscriptionId, + }); + } + + /** + * Process payment via token + * + * @param input - data for payment processing + */ + public async payByToken(input: PayWithTokenPayload): Promise { + return (await this.api.post('/payments/tokens/charge', input)).data; + } + + /** + * Cancels the payment by transaction ID + * + * @param transactionId - transaction id to cancel + */ + public async cancelPayment(transactionId: number): Promise { + const result = await this.api.post('/payments/void', { + TransactionId: transactionId, + }); + + if (!result.data.Success) { + throw new Error(`Error during cancelling transaction: ${result.data.Message}`); + } + } +} + +export default new CloudPaymentsApi({ + publicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '', + secret: process.env.CLOUDPAYMENTS_SECRET || '', +}); diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 00000000..90e68261 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,13 @@ +# Hawk API integration tests + +This folder contains integration tests for API. +All services and tests are started through docker. + +Run following command from the project root to run the tests: +``` +docker-compose -f docker-compose.test.yml up --exit-code-from tests tests +``` +or via yarn: +```shell +yarn test:integration +``` diff --git a/test/integration/accounting.env b/test/integration/accounting.env new file mode 100644 index 00000000..70e4ed50 --- /dev/null +++ b/test/integration/accounting.env @@ -0,0 +1,22 @@ +# API server port +PORT=3999 + +# Option to enable playground (if true playground will be available at /graphql route) +PLAYGROUND_ENABLE=false + +## Hawk Catcher token from hawk.so +HAWK_CATCHER_TOKEN= + +# MongoDB URL for connecting to accounting database +# for running via mono repository +MONGO_ACCOUNTING_DATABASE_URI=mongodb://mongodb:27017/codex_accounting +# for running as standalone +# MONGO_ACCOUNTING_DATABASE_URI=mongodb://localhost:27017/codex_accounting + +# Accounting Cashbook account identifier +CASHBOOK_ACCOUNT_ID=1358955c-3211-471f-be6a-97cc4b771769 +CASHBOOK_ACCOUNT_NAME="Test cashbook account" + +# Accounting Revenue account identifier +REVENUE_ACCOUNT_ID=fcbae90d-508a-46c0-9bbf-1ee0d6e31987 +REVENUE_ACCOUNT_NAME="Test revenue account" diff --git a/test/integration/api.env b/test/integration/api.env new file mode 100644 index 00000000..69703097 --- /dev/null +++ b/test/integration/api.env @@ -0,0 +1,89 @@ +# API server port +PORT=4000 + +# Hawk API database URL +MONGO_HAWK_DB_URL=mongodb://mongodb:27017/hawk + +# Events database URL +MONGO_EVENTS_DB_URL=mongodb://mongodb:27017/hawk_events + + + +# MongoDB settings +MONGO_RECONNECT_TRIES=60 +MONGO_RECONNECT_INTERVAL=1000 + +# JWT secret for user's refresh token +JWT_SECRET_REFRESH_TOKEN=abacaba + +# JWT secret for user's access token +JWT_SECRET_ACCESS_TOKEN=belarus + +# JWT secret for project's tokens (used in catcher) +JWT_SECRET_PROJECT_TOKEN=qwerty + +# JWT secret for signing tokens for processing billing requests +JWT_SECRET_BILLING_CHECKSUM=checksum_secret + +# Option to enable playground (if true playground will be available at /graphql route) +PLAYGROUND_ENABLE=true + +# SMTP mail settings + +## SMTP server adress +SMTP_HOST=smtp.yandex.ru + +## SMTP server port +SMTP_PORT=465 + +## SMTP server username (for examle, from your Yandex account) +SMTP_USERNAME= + +## SMTP server password (for examle, from your Yandex account) +SMTP_PASSWORD= + +## Sender name +SMTP_SENDER_NAME= + +## Mail from which letters are sent +SMTP_SENDER_ADDRESS= + +# AMQP URL +AMQP_URL=amqp://guest:guest@rabbitmq:5672/ + +# Billing settings +BILLING_DEBUG=true +BILLING_COMPANY_EMAIL="team@hawk.so" + +### Accounting module ### +# Accounting service URL +CODEX_ACCOUNTING_URL=http://accounting:3999/graphql + +# Enable or disable tls verify +TLS_VERIFY=false + +# Files with certs +TLS_CA_CERT=/usr/src/app/src/accounting/ca.pem +TLS_CERT=/usr/src/app/src/accounting/tls/client.pem +TLS_KEY=/usr/src/app/src/accounting/tls/client-key.pem + +## GitHub OAuth app client ID +GITHUB_CLIENT_ID=fakedata + +## GitHub OAuth app clinet secret +GITHUB_CLIENT_SECRET=fakedata + +## Hawk API public url (used in OAuth to redirect to callback, should match OAuth app callback URL) +API_URL=http://127.0.0.1:4000 + +## Garage url +GARAGE_URL=http://127.0.0.1:8080 + +## Garage login url +GARAGE_LOGIN_URL=http://127.0.0.1:8080/login + +## Upload dir +UPLOAD_DIR=uploads + +## Hawk Catcher token from hawk.so +HAWK_CATCHER_TOKEN= diff --git a/test/integration/billingMocks.ts b/test/integration/billingMocks.ts new file mode 100644 index 00000000..04b46279 --- /dev/null +++ b/test/integration/billingMocks.ts @@ -0,0 +1,54 @@ +import { UserDBScheme, UserNotificationType } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; +import { CheckRequest } from '../../src/billing/types'; +import { CardType, Currency, OperationStatus, OperationType } from '../../src/billing/types/enums'; + +export const user: UserDBScheme = { + _id: new ObjectId(), + notifications: { + whatToReceive: { + [UserNotificationType.IssueAssigning]: true, + [UserNotificationType.SystemMessages]: true, + [UserNotificationType.WeeklyDigest]: true, + }, + channels: { + email: { + isEnabled: true, + endpoint: 'test@hawk.so', + minPeriod: 10, + }, + }, + }, +}; +export const transactionId = 880555; +/** + * Basic check request + */ +export const mainRequest: CheckRequest = { + Amount: '20', + CardExpDate: '06/25', + CardFirstSix: '578946', + CardLastFour: '5367', + CardType: CardType.VISA, + Currency: Currency.USD, + DateTime: new Date(), + OperationType: OperationType.PAYMENT, + Status: OperationStatus.COMPLETED, + TestMode: false, + TransactionId: transactionId, + Issuer: 'Codex Bank', +}; + +/** + * Generates request for payment via subscription + * + * @param accountId - id of the account who makes payment + */ +export function getRequestWithSubscription(accountId: string): CheckRequest { + return { + ...mainRequest, + SubscriptionId: '123', + AccountId: accountId, + Amount: '10', + }; +} diff --git a/test/integration/cases/billing/check.test.ts b/test/integration/cases/billing/check.test.ts new file mode 100644 index 00000000..a700d792 --- /dev/null +++ b/test/integration/cases/billing/check.test.ts @@ -0,0 +1,306 @@ +import { apiInstance } from '../../utils'; +import { CheckCodes, CheckRequest } from '../../../../src/billing/types'; +import { Collection, Db } from 'mongodb'; +import { + BusinessOperationDBScheme, + BusinessOperationStatus, + ConfirmedMemberDBScheme, + PlanDBScheme, + UserDBScheme, + WorkspaceDBScheme +} from '@hawk.so/types'; +import checksumService from '../../../../src/utils/checksumService'; +import { getRequestWithSubscription, mainRequest, transactionId } from '../../billingMocks'; +import type { Global } from '@jest/types'; + +declare var global: Global.Global; + +describe('Check webhook', () => { + let accountsDb: Db; + + let businessOperationsCollection: Collection; + let workspacesCollection: Collection; + let plans: Collection; + let users: Collection; + + let workspace: WorkspaceDBScheme; + let externalUser: UserDBScheme; + let member: UserDBScheme; + let admin: UserDBScheme; + let planToChange: PlanDBScheme; + + beforeAll(async () => { + accountsDb = await global.mongoClient.db('hawk'); + + workspacesCollection = await accountsDb.collection('workspaces'); + users = await accountsDb.collection('users'); + plans = await accountsDb.collection('plans'); + + businessOperationsCollection = await accountsDb.collection('businessOperations'); + }); + + beforeEach(async () => { + const currentPlan = (await plans.insertOne({ + name: 'CurrentTestPlan', + monthlyCharge: 10, + monthlyChargeCurrency: 'USD', + eventsLimit: 1000, + isDefault: false, + })).ops[0]; + + workspace = (await workspacesCollection.insertOne({ + name: 'BillingTest', + accountId: '123', + tariffPlanId: currentPlan._id, + } as WorkspaceDBScheme)).ops[0]; + + externalUser = (await users.insertOne({ + email: 'user@billing.test', + })).ops[0]; + + member = (await users.insertOne({ + email: 'member@billing.test', + })).ops[0]; + + admin = (await users.insertOne({ + email: 'admin@billing.test', + })).ops[0]; + + planToChange = (await plans.insertOne({ + name: 'BasicTest', + monthlyCharge: 20, + monthlyChargeCurrency: 'USD', + eventsLimit: 10000, + isDefault: false, + })).ops[0]; + + const team = await accountsDb.collection(`team:${workspace._id.toString()}`); + + await team.insertOne({ + userId: member._id, + }); + + await team.insertOne({ + userId: admin._id, + isAdmin: true, + }); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + }); + + describe('With SubscriptionId field only', () => { + test('Should create business operation for workspace with that SubscriptionId', async () => { + const request = getRequestWithSubscription(admin._id.toString()); + + await workspacesCollection.updateOne( + { _id: workspace._id }, + { $set: { subscriptionId: request.SubscriptionId } } + ); + + const apiResponse = await apiInstance.post('/billing/check', request); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); + expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should prohibit payment if no workspace with provided SubscriptionId was found', async () => { + const request = getRequestWithSubscription(admin._id.toString()); + + const apiResponse = await apiInstance.post('/billing/check', request); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + expect(createdBusinessOperation).toBe(null); + }); + }); + + describe('With SubscriptionId field and Data field', () => { + test.todo('Should prohibit payment if the workspace already has a subscription'); + }); + + describe('With Data field', () => { + test('Should not accept request without necessary data', async () => { + /** + * Request without Data field + */ + const data: CheckRequest = { + ...mainRequest, + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + }); + + test('Should not accept request with a non-existent workspace id', async () => { + /** + * Request with a non-existent workspace id + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: '5fe383b0126d28907780641b', + userId: admin._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + }); + + test('Should not accept request if user is not a member of the workspace', async () => { + /** + * Request with a user who is not a member of the workspace + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: externalUser._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + }); + + test('Should not accept request if user is not an admin', async () => { + /** + * Request with a user who is not an admin of the workspace + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: member._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + }); + + test('Should not accept request with non-existent plan', async () => { + /** + * Request with a non-existent plan id + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: admin._id.toString(), + tariffPlanId: '5fe383b0126d28007780641b', + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED); + }); + + test('Should not accept request because amount in request doesn\'t match with plan monthly charge', async () => { + /** + * Request with amount that does not match the cost of the plan + */ + const data: CheckRequest = { + ...mainRequest, + Amount: '20.45', + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: admin._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + + expect(apiResponse.data.code).toBe(CheckCodes.WRONG_AMOUNT); + }); + + test('Should create business operation with pending status', async () => { + /** + * Correct data + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: admin._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); + expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + }); + + test('Should allow request with amount = 1$, in case of deferred payment', async () => { + /** + * Correct data + */ + const data: CheckRequest = { + ...mainRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + workspaceId: workspace._id.toString(), + userId: admin._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + cloudPayments: { + recurrent: { + interval: 'Month', + period: 1, + startDate: new Date().toString(), + amount: 1, + }, + }, + }), + }; + + const apiResponse = await apiInstance.post('/billing/check', data); + const createdBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); + expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); +}); diff --git a/test/integration/cases/billing/fail.test.ts b/test/integration/cases/billing/fail.test.ts new file mode 100644 index 00000000..02c6618f --- /dev/null +++ b/test/integration/cases/billing/fail.test.ts @@ -0,0 +1,254 @@ +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 { WorkerPaths } from '../../../../src/rabbitmq'; +import { PaymentFailedNotificationTask, SenderWorkerTaskType } from '../../../../src/types/personalNotifications'; +import checksumService from '../../../../src/utils/checksumService'; +import type { Global } from '@jest/types'; + +declare var global: Global.Global; + +const transactionId = 909090; + +const user: UserDBScheme = { + _id: new ObjectId(), + notifications: { + whatToReceive: { + [UserNotificationType.IssueAssigning]: true, + [UserNotificationType.SystemMessages]: true, + [UserNotificationType.WeeklyDigest]: true, + }, + channels: { + email: { + isEnabled: true, + endpoint: 'test@hawk.so', + minPeriod: 10, + }, + }, + }, +}; + +const workspace = { + _id: new ObjectId(), + accountId: '123', + balance: 0, + billingPeriodEventsCount: 1000, + lastChargeDate: new Date(2020, 10, 4), + name: 'Test workspace', + tariffPlanId: new ObjectId(), + inviteHash: '123456789', +}; + +const tariffPlan: PlanDBScheme = { + _id: new ObjectId(), + eventsLimit: 10000, + isDefault: true, + monthlyCharge: 100, + monthlyChargeCurrency: 'USD', + name: 'Test plan', +}; + +const planProlongationPayload: PlanProlongationPayload = { + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: tariffPlan._id.toString(), + shouldSaveCard: false, +}; + +const validRequest: FailRequest = { + Amount: 100, + CardExpDate: '06/25', + CardFirstSix: '578946', + CardLastFour: '5367', + CardType: CardType.VISA, + Currency: Currency.USD, + DateTime: new Date(), + OperationType: OperationType.PAYMENT, + TestMode: false, + TransactionId: transactionId, + Issuer: 'Codex Bank', + Reason: 'DoNotHonor', + ReasonCode: ReasonCode.DO_NOT_HONOR, +}; + +describe('Fail webhook', () => { + let accountsDb: Db; + let businessOperationsCollection: Collection; + let workspacesCollection: Collection; + let usersCollection: Collection; + + beforeAll(async () => { + accountsDb = await global.mongoClient.db('hawk'); + + businessOperationsCollection = await accountsDb.collection('businessOperations'); + workspacesCollection = accountsDb.collection('workspaces'); + usersCollection = accountsDb.collection('users'); + }); + + beforeEach(async () => { + /** + * Add user who makes payment + */ + await usersCollection.insertOne(user); + + /** + * Add workspace for testing it + */ + await workspacesCollection.insertOne(workspace); + + /** + * Add pending business operation to database (like after /billing/check route) + */ + await businessOperationsCollection.insertOne({ + transactionId: transactionId.toString(), + type: BusinessOperationType.DepositByUser, + status: BusinessOperationStatus.Pending, + dtCreated: new Date(), + payload: { + workspaceId: workspace._id, + amount: 200, + currency: 'USD', + userId: user._id, + cardPan: '5367', + }, + }); + + /** + * Clear rabbitmq queue + */ + await global.rabbitChannel.purgeQueue(WorkerPaths.Email.queue); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + }); + + describe('With SubscriptionId only', () => { + const request: FailRequest = { + ...validRequest, + SubscriptionId: '123', + AccountId: user._id.toString(), + }; + + request.TransactionId = transactionId; + + beforeEach(async () => { + await workspacesCollection.updateOne( + { _id: workspace._id }, + { $set: { subscriptionId: request.SubscriptionId } } + ); + }); + + test('Should change business operation status to rejected', async () => { + const apiResponse = await apiInstance.post('/billing/fail', request); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Rejected); + }); + + test('Should add task to sender worker to notify user about payment rejection', async () => { + const apiResponse = await apiInstance.post('/billing/fail', request); + + const message = await global.rabbitChannel.get(WorkerPaths.Email.queue, { + noAck: true, + }); + const expectedLimiterTask: PaymentFailedNotificationTask = { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + endpoint: 'test@hawk.so', + workspaceId: workspace._id.toString(), + reason: ReasonCodesTranscript[validRequest.ReasonCode], + }, + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + }); + }); + + describe('With valid request', () => { + test('Should change business operation status to rejected', async () => { + const apiResponse = await apiInstance.post('/billing/fail', { + ...validRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum(planProlongationPayload), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Rejected); + }); + + test('Should add task to sender worker to notify user about payment rejection', async () => { + const apiResponse = await apiInstance.post('/billing/fail', { + ...validRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum(planProlongationPayload), + }), + }); + + const message = await global.rabbitChannel.get(WorkerPaths.Email.queue, { + noAck: true, + }); + const expectedLimiterTask: PaymentFailedNotificationTask = { + type: SenderWorkerTaskType.PaymentFailed, + payload: { + endpoint: 'test@hawk.so', + workspaceId: workspace._id.toString(), + reason: ReasonCodesTranscript[validRequest.ReasonCode], + }, + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + }); + }); + + describe('With invalid request', () => { + test('Should not change business operation status if no data provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { Data, ...invalidRequest } = validRequest; + const apiResponse = await apiInstance.post('/billing/fail', invalidRequest); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no user id provided', async () => { + const apiResponse = await apiInstance.post('/billing/fail', { + ...validRequest, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: '', + workspaceId: workspace._id.toString(), + tariffPlanId: tariffPlan._id.toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(FailCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + }); +}); diff --git a/test/integration/cases/billing/pay.test.ts b/test/integration/cases/billing/pay.test.ts new file mode 100644 index 00000000..65695589 --- /dev/null +++ b/test/integration/cases/billing/pay.test.ts @@ -0,0 +1,622 @@ +import { accountingEnv, apiInstance } from '../../utils'; +import { PayCodes, PayRequest } from '../../../../src/billing/types'; +import { CardType, Currency, OperationStatus, OperationType } from '../../../../src/billing/types/enums'; +import { Collection, Db, MongoClient, ObjectId } from 'mongodb'; +import { + BusinessOperationDBScheme, + BusinessOperationStatus, + BusinessOperationType, + PlanDBScheme, + UserDBScheme, + WorkspaceDBScheme +} from '@hawk.so/types'; +import { + PaymentSuccessNotificationPayload, + PaymentSuccessNotificationTask, + SenderWorkerTaskType +} from '../../../../src/types/personalNotifications'; +import { WorkerPaths } from '../../../../src/rabbitmq'; +import checksumService from '../../../../src/utils/checksumService'; +import { getRequestWithSubscription, user } from '../../billingMocks'; +import { CardDetails } from '../../../../src/billing/types/cardDetails'; +import type { Global } from '@jest/types'; + +declare var global: Global.Global; + +const transactionId = 123456; + +const currentPlan: PlanDBScheme = { + _id: new ObjectId(), + eventsLimit: 1000, + isDefault: true, + monthlyCharge: 1000, + monthlyChargeCurrency: 'USD', + name: 'Test plan', +}; + +const cardDetails: Required = { + CardExpDate: '25/03', + CardType: CardType.VISA, + Token: '123123', + CardFirstSix: '545636', + CardLastFour: '4555', +}; + +const workspace = { + _id: new ObjectId(), + accountId: '123', + balance: 0, + billingPeriodEventsCount: 1000, + lastChargeDate: new Date(2020, 10, 4), + name: 'Test workspace', + tariffPlanId: currentPlan._id, + inviteHash: '123456789', +}; + +const workspaceAccount = { + id: workspace.accountId, + name: 'WORKSPACE:' + workspace.name, + type: 'Liability', + currency: 'USD', + dtCreated: Date.now(), +}; + +const planToChange: PlanDBScheme = { + _id: new ObjectId(), + eventsLimit: 10000, + isDefault: true, + monthlyCharge: 100, + monthlyChargeCurrency: 'USD', + name: 'Test plan', +}; + +const cashbookAccount = { + id: accountingEnv.CASHBOOK_ACCOUNT_ID, + name: accountingEnv.CASHBOOK_ACCOUNT_NAME, + type: 'Asset', + currency: 'USD', + dtCreated: Date.now(), +}; + +const revenueAccount = { + id: accountingEnv.REVENUE_ACCOUNT_ID, + name: accountingEnv.REVENUE_ACCOUNT_NAME, + type: 'Revenue', + currency: 'USD', + dtCreated: Date.now(), +}; + +const paymentSuccessPayload: PaymentSuccessNotificationPayload = { + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: planToChange._id.toString(), +}; + +/** + * Valid data to send to `pay` webhook + * Initializes later in beforeAll + */ +let validPayRequestData: PayRequest; + +describe('Pay webhook', () => { + let accountsDb: Db; + let accountingDb: Db; + let usersCollection: Collection; + let businessOperationsCollection: Collection; + let workspacesCollection: Collection; + let tariffPlanCollection: Collection; + // let accountingCollection: Collection; + // let transactionsCollection: Collection; + + beforeAll(async () => { + validPayRequestData = { + Amount: '10', + CardExpDate: '06/25', + CardFirstSix: '578946', + CardLastFour: '5367', + CardType: CardType.VISA, + Currency: Currency.RUB, + DateTime: new Date(), + GatewayName: 'CodeX bank', + OperationType: OperationType.PAYMENT, + Status: OperationStatus.COMPLETED, + TestMode: false, + TotalFee: 0, + TransactionId: transactionId, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + ...paymentSuccessPayload, + shouldSaveCard: false, + }), + }), + }; + + accountsDb = await global.mongoClient.db('hawk'); + accountingDb = await global.mongoClient.db('codex_accounting'); + + usersCollection = accountsDb.collection('users'); + businessOperationsCollection = accountsDb.collection('businessOperations'); + workspacesCollection = accountsDb.collection('workspaces'); + tariffPlanCollection = accountsDb.collection('plans'); + + // transactionsCollection = accountingDb.collection('transactions'); + // accountingCollection = accountingDb.collection('accounts'); + }); + + beforeEach(async () => { + /** + * Add user who makes payment + */ + await usersCollection.insertOne(user); + + /** + * Add pending business operation to database (like after /billing/check route) + */ + await businessOperationsCollection.insertOne({ + transactionId: transactionId.toString(), + type: BusinessOperationType.DepositByUser, + status: BusinessOperationStatus.Pending, + dtCreated: new Date(), + payload: { + workspaceId: new ObjectId(), + amount: 10, + currency: 'USD', + userId: user._id, + cardPan: '2456', + }, + }); + + /** + * Add workspace for testing it + */ + await workspacesCollection.insertOne(workspace); + + /** + * Add tariff plans for testing + */ + await tariffPlanCollection.insertOne(currentPlan); + await tariffPlanCollection.insertOne(planToChange); + + /** + * Add necessary accounts to accounting system + */ + // await accountingCollection.insertMany([cashbookAccount, revenueAccount, workspaceAccount]); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + await accountingDb.dropDatabase(); + await global.rabbitChannel.purgeQueue(WorkerPaths.Limiter.queue); + await global.rabbitChannel.purgeQueue(WorkerPaths.Email.queue); + }); + + describe('With SubscriptionId field only', () => { + const request = getRequestWithSubscription(user._id.toString()); + + request.TransactionId = transactionId; + + beforeEach(async () => { + await workspacesCollection.updateOne( + { _id: workspace._id }, + { $set: { subscriptionId: request.SubscriptionId } } + ); + }); + + test('Should change business operation status to confirmed', async () => { + const apiResponse = await apiInstance.post('/billing/pay', request); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Confirmed); + }); + + test('Should reset events counter in workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', request); + + const updatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.billingPeriodEventsCount).toBe(0); + }); + + test('Should reset last charge date in workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', request); + + const updatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.lastChargeDate).not.toBe(workspace.lastChargeDate); + }); + + test('Should send task to limiter worker to check workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', request); + + const message = await global.rabbitChannel.get('cron-tasks/limiter', { + noAck: true, + }); + const expectedLimiterTask = { + type: 'check-single-workspace', + workspaceId: workspace._id.toString(), + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + 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 + // */ + // await workspacesCollection.updateOne( + // { _id: workspace._id }, + // { + // $unset: { + // accountId: '', + // }, + // } + // ); + + // const apiResponse = await apiInstance.post('/billing/pay', request); + + // /** + // * Check that account is created and linked + // */ + // const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + // const accountId = updatedWorkspace?.accountId; + // const account = await accountingCollection.findOne({ id: accountId }); + + // expect(typeof accountId).toBe('string'); + // expect(account).toBeTruthy(); + // expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + // }); + + // test('Should add payment data to accounting system', async () => { + // const apiResponse = await apiInstance.post('/billing/pay', request); + + // const transactions = await transactionsCollection + // .find({}) + // .toArray(); + + // expect(transactions.length).toBe(2); + // expect(transactions.some(tr => tr.type === 'Deposit')); + // expect(transactions.some(tr => tr.type === 'Purchase')); + // expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + // }); + + test('Should add task to sender worker to notify user about successful payment', async () => { + const apiResponse = await apiInstance.post('/billing/pay', request); + + const message = await global.rabbitChannel.get(WorkerPaths.Email.queue, { + noAck: true, + }); + const expectedLimiterTask: PaymentSuccessNotificationTask = { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + endpoint: 'test@hawk.so', + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: currentPlan._id.toString(), + }, + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + }); + }); + + describe('With valid request', () => { + test('Should change business operation status to confirmed', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Confirmed); + }); + + test('Should reset events counter in workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const updatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.billingPeriodEventsCount).toBe(0); + }); + + test('Should reset last charge date in workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const updatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.lastChargeDate).not.toBe(workspace.lastChargeDate); + }); + + test('Should change workspace plan', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const updatedWorkspace = await workspacesCollection.findOne({ + _id: workspace._id, + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedWorkspace?.tariffPlanId.toString()).toBe(planToChange._id.toString()); + }); + + test('Should send task to limiter worker to check workspace', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const message = await global.rabbitChannel.get('cron-tasks/limiter', { + noAck: true, + }); + const expectedLimiterTask = { + type: 'check-single-workspace', + workspaceId: workspace._id.toString(), + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + 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 + // */ + // await workspacesCollection.updateOne( + // { _id: workspace._id }, + // { + // $unset: { + // accountId: '', + // }, + // } + // ); + + // const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + // /** + // * Check that account is created and linked + // */ + // const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + // const accountId = updatedWorkspace?.accountId; + // const account = await accountingCollection.findOne({ id: accountId }); + + // expect(typeof accountId).toBe('string'); + // expect(account).toBeTruthy(); + // expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + // }); + + // test('Should add payment data to accounting system', async () => { + // const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + // const transactions = await transactionsCollection + // .find({}) + // .toArray(); + + // expect(transactions.length).toBe(2); + // expect(transactions.some(tr => tr.type === 'Deposit')); + // expect(transactions.some(tr => tr.type === 'Purchase')); + // expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + // }); + + test('Should add task to sender worker to notify user about successful payment', async () => { + const apiResponse = await apiInstance.post('/billing/pay', validPayRequestData); + + const message = await global.rabbitChannel.get(WorkerPaths.Email.queue, { + noAck: true, + }); + const expectedLimiterTask: PaymentSuccessNotificationTask = { + type: SenderWorkerTaskType.PaymentSuccess, + payload: { + endpoint: 'test@hawk.so', + ...paymentSuccessPayload, + }, + }; + + expect(message).toBeTruthy(); + expect(message && JSON.parse(message.content.toString())).toStrictEqual(expectedLimiterTask); + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + }); + + test('Should save SubscriptionId if it is provided in request', async () => { + const request = { + ...validPayRequestData, + SubscriptionId: '123', + }; + + const apiResponse = await apiInstance.post('/billing/pay', request); + + const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + + expect(updatedWorkspace?.subscriptionId).toBe(request.SubscriptionId); + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + }); + + test('Should save user card if shouldSaveCard true', async () => { + /** + * Correct data + */ + const request: PayRequest = { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + ...paymentSuccessPayload, + shouldSaveCard: true, + }), + }), + ...cardDetails, + }; + + const apiResponse = await apiInstance.post('/billing/pay', request); + const updatedUser = await usersCollection.findOne({ _id: user._id }); + + const expectedCard = { + cardExpDate: cardDetails.CardExpDate, + firstSix: +cardDetails.CardFirstSix, + lastFour: +cardDetails.CardLastFour, + token: cardDetails.Token, + type: cardDetails.CardType, + }; + + expect(updatedUser?.bankCards?.length).toBe(1); + expect(updatedUser?.bankCards?.shift()).toMatchObject(expectedCard); + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + }); + }); + + describe('With invalid request', () => { + test('Should not change business operation status if no data or subscription id provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { Data, ...invalidRequest } = validPayRequestData; + const apiResponse = await apiInstance.post('/billing/pay', invalidRequest); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no workspace id provided', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: user._id.toString(), + workspaceId: '', + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no user id provided', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: '', + workspaceId: workspace._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no tariff plan id provided', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: user._id.toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: '', + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no workspaces with provided id', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: user._id.toString(), + workspaceId: new ObjectId().toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no tariff plan with provided id', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: new ObjectId().toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: new ObjectId().toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + + test('Should not change business operation status if no user with provided id', async () => { + const apiResponse = await apiInstance.post('/billing/pay', { + ...validPayRequestData, + Data: JSON.stringify({ + checksum: await checksumService.generateChecksum({ + userId: new ObjectId().toString(), + workspaceId: workspace._id.toString(), + tariffPlanId: planToChange._id.toString(), + shouldSaveCard: false, + }), + }), + }); + + const updatedBusinessOperation = await businessOperationsCollection.findOne({ + transactionId: transactionId.toString(), + }); + + expect(apiResponse.data.code).toBe(PayCodes.SUCCESS); + expect(updatedBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); + }); + }); +}); diff --git a/test/integration/cases/billing/recurrent.test.ts b/test/integration/cases/billing/recurrent.test.ts new file mode 100644 index 00000000..ec795175 --- /dev/null +++ b/test/integration/cases/billing/recurrent.test.ts @@ -0,0 +1,94 @@ +import { Collection, Db, ObjectId } from 'mongodb'; +import { PlanDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; +import { RecurrentCodes, RecurrentRequest } from '../../../../src/billing/types'; +import { Currency, Interval, SubscriptionStatus } from '../../../../src/billing/types/enums'; +import { apiInstance } from '../../utils'; +import type { Global } from '@jest/types'; + +declare var global: Global.Global; + +const currentPlan: PlanDBScheme = { + _id: new ObjectId(), + eventsLimit: 1000, + isDefault: true, + monthlyCharge: 1000, + monthlyChargeCurrency: 'USD', + name: 'Test plan', +}; + +const workspace: WorkspaceDBScheme = { + _id: new ObjectId(), + accountId: '123', + balance: 0, + billingPeriodEventsCount: 1000, + lastChargeDate: new Date(2020, 10, 4), + name: 'Test workspace', + tariffPlanId: currentPlan._id, + inviteHash: '12345678', + subscriptionId: '123123', +}; + +const request: RecurrentRequest = { + AccountId: '123', + Amount: '123', + Currency: Currency.USD, + Description: 'Description', + Email: 'test@hawk.so', + FailedTransactionsNumber: 0, + Id: '123123', + Interval: Interval.MONTH, + Period: 0, + RequireConfirmation: false, + StartDate: '', + Status: SubscriptionStatus.CANCELLED, + SuccessfulTransactionsNumber: 0, +}; + +describe('Recurrent webhook', () => { + let accountsDb: Db; + let workspacesCollection: Collection; + + beforeAll(async () => { + accountsDb = await global.mongoClient.db('hawk'); + + workspacesCollection = accountsDb.collection('workspaces'); + }); + + beforeEach(async () => { + /** + * Add workspace for testing it + */ + await workspacesCollection.insertOne(workspace); + }); + + afterEach(async () => { + await accountsDb.dropDatabase(); + }); + + describe('Cancelled status', () => { + test('Should remove subscriptionId field from workspace', async () => { + const apiResponse = await apiInstance.post('/billing/recurrent', request); + + const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + + expect(apiResponse.data.code).toBe(RecurrentCodes.SUCCESS); + expect(updatedWorkspace?.subscriptionId).toBeFalsy(); + }); + test.todo('Should notify user about subscription cancelling'); + }); + + describe('Rejected status', () => { + test('Should remove subscriptionId field from workspace', async () => { + const apiResponse = await apiInstance.post('/billing/recurrent', { + ...request, + Status: SubscriptionStatus.REJECTED, + }); + + const updatedWorkspace = await workspacesCollection.findOne({ _id: workspace._id }); + + expect(apiResponse.data.code).toBe(RecurrentCodes.SUCCESS); + expect(updatedWorkspace?.subscriptionId).toBeFalsy(); + }); + test.todo('Should notify user about subscription cancelling'); + }); +}); diff --git a/test/integration/cases/health.test.ts b/test/integration/cases/health.test.ts new file mode 100644 index 00000000..ba5a1090 --- /dev/null +++ b/test/integration/cases/health.test.ts @@ -0,0 +1,9 @@ +import { apiInstance } from '../utils'; + +describe('Server health', () => { + test('Server is healthy', async () => { + const response = await apiInstance.get('.well-known/apollo/server-health'); + + expect(response.status).toBe(200); + }); +}); diff --git a/test/integration/jest.config.js b/test/integration/jest.config.js new file mode 100644 index 00000000..234886e5 --- /dev/null +++ b/test/integration/jest.config.js @@ -0,0 +1,18 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +module.exports = { + /** + * The test environment that will be used for testing + */ + testEnvironment: './jestEnv.js', + + /** + * TypeScript support + */ + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, +}; diff --git a/test/integration/jestEnv.js b/test/integration/jestEnv.js new file mode 100644 index 00000000..cedfba31 --- /dev/null +++ b/test/integration/jestEnv.js @@ -0,0 +1,52 @@ +const NodeEnvironment = require('jest-environment-node'); +const amqp = require('amqplib'); +const mongodb = require('mongodb'); + +/** + * Custom test environment for defining global connections + */ +class CustomEnvironment extends NodeEnvironment { + /** + * Setup environment + * @return {Promise} + */ + async setup() { + await super.setup(); + const mongoClient = new mongodb.MongoClient('mongodb://mongodb:27017', { useUnifiedTopology: true }); + + await mongoClient.connect(); + this.global.mongoClient = mongoClient; + await mongoClient.db('hawk').dropDatabase(); + // await mongoClient.db('codex_accounting').dropDatabase(); + + this.rabbitMqConnection = await amqp.connect('amqp://guest:guest@rabbitmq:5672/'); + this.global.rabbitChannel = await this.rabbitMqConnection.createChannel(); + await this.global.rabbitChannel.purgeQueue('cron-tasks/limiter'); + } + + /** + * Teardown environment + * @return {Promise} + */ + async teardown() { + try { + if (this.global.mongoClient) { + await this.global.mongoClient.close(); + } + + if (this.global.rabbitChannel) { + await this.global.rabbitChannel.close(); + } + + if (this.rabbitMqConnection) { + await this.rabbitMqConnection.close(); + } + } catch (error) { + console.error('Error during teardown:', error); + } + + await super.teardown(); + } +} + +module.exports = CustomEnvironment; diff --git a/test/integration/mocks.ts b/test/integration/mocks.ts new file mode 100644 index 00000000..04b46279 --- /dev/null +++ b/test/integration/mocks.ts @@ -0,0 +1,54 @@ +import { UserDBScheme, UserNotificationType } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; +import { CheckRequest } from '../../src/billing/types'; +import { CardType, Currency, OperationStatus, OperationType } from '../../src/billing/types/enums'; + +export const user: UserDBScheme = { + _id: new ObjectId(), + notifications: { + whatToReceive: { + [UserNotificationType.IssueAssigning]: true, + [UserNotificationType.SystemMessages]: true, + [UserNotificationType.WeeklyDigest]: true, + }, + channels: { + email: { + isEnabled: true, + endpoint: 'test@hawk.so', + minPeriod: 10, + }, + }, + }, +}; +export const transactionId = 880555; +/** + * Basic check request + */ +export const mainRequest: CheckRequest = { + Amount: '20', + CardExpDate: '06/25', + CardFirstSix: '578946', + CardLastFour: '5367', + CardType: CardType.VISA, + Currency: Currency.USD, + DateTime: new Date(), + OperationType: OperationType.PAYMENT, + Status: OperationStatus.COMPLETED, + TestMode: false, + TransactionId: transactionId, + Issuer: 'Codex Bank', +}; + +/** + * Generates request for payment via subscription + * + * @param accountId - id of the account who makes payment + */ +export function getRequestWithSubscription(accountId: string): CheckRequest { + return { + ...mainRequest, + SubscriptionId: '123', + AccountId: accountId, + Amount: '10', + }; +} diff --git a/test/integration/rabbit.definitions.json b/test/integration/rabbit.definitions.json new file mode 100644 index 00000000..cb39e8b4 --- /dev/null +++ b/test/integration/rabbit.definitions.json @@ -0,0 +1,452 @@ +{ + "//": { + "rabbit_version": "3.7.8" + }, + "users": [ + { + "name": "guest", + "password_hash": "A+lasMnIk+fu2IA6GRuRBAxyps9F2eoWmMnrpBuuMn2QqcS/", + "hashing_algorithm": "rabbit_password_hashing_sha256", + "tags": "administrator" + } + ], + "vhosts": [ + { + "name": "/" + } + ], + "permissions": [ + { + "user": "guest", + "vhost": "/", + "configure": ".*", + "write": ".*", + "read": ".*" + } + ], + "topic_permissions": [], + "parameters": [], + "//": { + "global_parameters": [ + { + "name": "cluster_name", + "value": "rabbit@6542d9b6d360" + } + ] + }, + "policies": [ + { + "vhost": "/", + "name": "stash", + "pattern": "^errors/", + "apply-to": "queues", + "definition": { + "dead-letter-exchange": "stash" + }, + "priority": 0 + }, + { + "vhost": "/", + "name": "stash-notify", + "pattern": "^notify/", + "apply-to": "queues", + "definition": { + "dead-letter-exchange": "stash" + }, + "priority": 0 + } + ], + "queues": [ + { + "name": "errors/golang", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/nodejs", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/javascript", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/client", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/php", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/python", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "errors/test", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/golang", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/nodejs", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/javascript", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/client", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/php", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/python", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/test", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "stash/notify", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "grouper", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "log", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "release/javascript", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "notifier", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "sender/email", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "sender/slack", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "sender/telegram", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "cron-tasks/archiver", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "cron-tasks/limiter", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, + { + "name": "cron-tasks/paymaster", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + } + ], + "exchanges": [ + { + "name": "errors", + "vhost": "/", + "type": "direct", + "durable": true, + "auto_delete": false, + "internal": false, + "arguments": {} + }, + { + "name": "stash", + "vhost": "/", + "type": "direct", + "durable": true, + "auto_delete": false, + "internal": false, + "arguments": {} + }, + { + "name": "notifier", + "vhost": "/", + "type": "direct", + "durable": true, + "auto_delete": false, + "internal": false, + "arguments": {} + }, + { + "name": "cron-tasks", + "vhost": "/", + "type": "direct", + "durable": true, + "auto_delete": false, + "internal": false, + "arguments": {} + } + ], + "bindings": [ + { + "source": "errors", + "vhost": "/", + "destination": "errors/client", + "destination_type": "queue", + "routing_key": "errors/client", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/golang", + "destination_type": "queue", + "routing_key": "errors/golang", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/nodejs", + "destination_type": "queue", + "routing_key": "errors/nodejs", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/javascript", + "destination_type": "queue", + "routing_key": "errors/javascript", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/php", + "destination_type": "queue", + "routing_key": "errors/php", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/python", + "destination_type": "queue", + "routing_key": "errors/python", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "errors/test", + "destination_type": "queue", + "routing_key": "errors/test", + "arguments": {} + }, + { + "source": "errors", + "vhost": "/", + "destination": "release/javascript", + "destination_type": "queue", + "routing_key": "release/javascript", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/client", + "destination_type": "queue", + "routing_key": "errors/client", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/golang", + "destination_type": "queue", + "routing_key": "errors/golang", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/nodejs", + "destination_type": "queue", + "routing_key": "errors/nodejs", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/php", + "destination_type": "queue", + "routing_key": "errors/php", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/python", + "destination_type": "queue", + "routing_key": "errors/python", + "arguments": {} + }, + { + "source": "stash", + "vhost": "/", + "destination": "stash/test", + "destination_type": "queue", + "routing_key": "errors/test", + "arguments": {} + }, + { + "source": "notifier", + "vhost": "/", + "destination": "notifier", + "destination_type": "queue", + "routing_key": "notifier", + "arguments": {} + }, + { + "source": "notifier", + "vhost": "/", + "destination": "notifier", + "destination_type": "queue", + "routing_key": "notifier", + "arguments": {} + }, + { + "source": "notifier", + "vhost": "/", + "destination": "sender/email", + "destination_type": "queue", + "routing_key": "sender/email", + "arguments": {} + }, + { + "source": "notifier", + "vhost": "/", + "destination": "sender/slack", + "destination_type": "queue", + "routing_key": "sender/slack", + "arguments": {} + }, + { + "source": "notifier", + "vhost": "/", + "destination": "sender/telegram", + "destination_type": "queue", + "routing_key": "sender/slack", + "arguments": {} + }, + { + "source": "cron-tasks", + "vhost": "/", + "destination": "cron-tasks/archiver", + "destination_type": "queue", + "routing_key": "cron-tasks/archiver", + "arguments": {} + }, + { + "source": "cron-tasks", + "vhost": "/", + "destination": "cron-tasks/limiter", + "destination_type": "queue", + "routing_key": "cron-tasks/limiter", + "arguments": {} + }, + { + "source": "cron-tasks", + "vhost": "/", + "destination": "cron-tasks/paymaster", + "destination_type": "queue", + "routing_key": "cron-tasks/paymaster", + "arguments": {} + } + ] +} diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json new file mode 100644 index 00000000..5a2b85a0 --- /dev/null +++ b/test/integration/tsconfig.json @@ -0,0 +1,67 @@ +{ + "compilerOptions": { + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + + } +} diff --git a/test/integration/types/global.d.ts b/test/integration/types/global.d.ts new file mode 100644 index 00000000..49a3eaa8 --- /dev/null +++ b/test/integration/types/global.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { MongoClient } from 'mongodb'; +import { Channel } from 'amqplib'; + +/** + * Defines global variables for using in tests + */ +declare global { + namespace NodeJS { + interface Global { + /** + * MongoDB client instance + */ + mongoClient: MongoClient; + + /** + * RabbitMQ client instance + */ + rabbitChannel: Channel; + } + } +} diff --git a/test/integration/utils.ts b/test/integration/utils.ts new file mode 100644 index 00000000..26701341 --- /dev/null +++ b/test/integration/utils.ts @@ -0,0 +1,20 @@ +import dotenv from 'dotenv'; +import axios from 'axios'; +import path from 'path'; + +/** + * Env variables for API + */ +export const apiEnv = dotenv.config({ path: path.join(__dirname, './api.env') }).parsed || {}; + +/** + * Env variables for Accounting + */ +export const accountingEnv = dotenv.config({ path: path.join(__dirname, './accounting.env') }).parsed || {}; + +/** + * Axios instance to send requests to API + */ +export const apiInstance = axios.create({ + baseURL: `http://api:${apiEnv.PORT}`, +}); diff --git a/test/models/businessOperation.test.ts b/test/models/businessOperation.test.ts index 49055655..07d63915 100644 --- a/test/models/businessOperation.test.ts +++ b/test/models/businessOperation.test.ts @@ -19,6 +19,7 @@ describe('Business operation model', () => { const payloadDepositByUser = { workspaceId: new ObjectId('5edd36fbb596d4759beb89f6'), amount: 100, + currency: 'USD', userId: new ObjectId('5eb9034a1ccc4421e2623dc2'), cardPan: '4455', }; @@ -42,6 +43,7 @@ describe('Business operation model', () => { amount: 100, userId: new ObjectId(), tariffPlanId: new ObjectId(), + currency: 'RUB', }; const data: BusinessOperationDBScheme = { diff --git a/test/models/businessOperationsFactory.test.ts b/test/models/businessOperationsFactory.test.ts index 682ba911..29dc5a6f 100644 --- a/test/models/businessOperationsFactory.test.ts +++ b/test/models/businessOperationsFactory.test.ts @@ -10,6 +10,7 @@ beforeAll(async () => { }); describe('Business operation factory', () => { + it('should create factory instance', () => { const factory = new BusinessOperationsFactory(mongo.databases.hawk as Db, new DataLoaders(mongo.databases.hawk as Db)); @@ -24,6 +25,7 @@ describe('Business operation factory', () => { amount: 100, userId: new ObjectId('5eb9034a1ccc4421e2623dc2'), cardPan: '4455', + currency: 'RUB', }; const data = { @@ -38,6 +40,7 @@ describe('Business operation factory', () => { expect(businessOperation).toMatchObject(data); }); + }); afterAll(async done => { @@ -46,3 +49,4 @@ afterAll(async done => { done(); }); + diff --git a/test/resolvers/encodedJSON.test.ts b/test/resolvers/encodedJSON.test.ts index c12b3d92..092bac82 100644 --- a/test/resolvers/encodedJSON.test.ts +++ b/test/resolvers/encodedJSON.test.ts @@ -43,30 +43,30 @@ describe('GraphQLEncodedJSON', () => { describe('serialize', () => { it('should support serialization from object', async () => { - const { data, errors } = await graphql( + const { data, errors } = await graphql({ schema, - ` + source: ` query { rootValue } `, - FIXTURE - ); + rootValue: FIXTURE + }); expect(data?.rootValue).toEqual(FIXTURE); expect(errors).toBeUndefined(); }); it('should support serialization from string', async () => { - const { data, errors } = await graphql( + const { data, errors } = await graphql({ schema, - ` + source: ` query { rootValue } `, - JSON.stringify(FIXTURE) - ); + rootValue: JSON.stringify(FIXTURE) + }); expect(data?.rootValue).toEqual(FIXTURE); expect(errors).toBeUndefined(); diff --git a/yarn.lock b/yarn.lock index 870b9f92..09987427 100644 --- a/yarn.lock +++ b/yarn.lock @@ -451,13 +451,20 @@ axios "^0.21.1" stack-trace "^0.0.10" -"@hawk.so/types@^0.1.15", "@hawk.so/types@^0.1.18": +"@hawk.so/types@^0.1.15": version "0.1.18" resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.18.tgz#746537634756825f066182737429d11ea124d5c5" integrity sha512-SvECLGmLb5t90OSpk3n8DCjJsUoyjrq/Z6Ioil80tVkbMXRdGjaHZpn/0w1gBqtgNWBfW2cSbsQPqmyDj1NsqQ== dependencies: "@types/mongodb" "^3.5.34" +"@hawk.so/types@^0.1.21": + version "0.1.21" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.21.tgz#8979b1aff670317e5d092aaefcd879729df88adc" + integrity sha512-Jjy1qznAB7SprVtdgWJS9tdGAaK7rTNWEVWG05m6u3Qd3tTXkgty4D0oAmhTFBnJ/MElPVuundYnDB1r4dHZQQ== + dependencies: + "@types/mongodb" "^3.5.34" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -771,6 +778,15 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@shelf/jest-mongodb@^1.2.2": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@shelf/jest-mongodb/-/jest-mongodb-1.3.4.tgz#200bac386cf513bed2d41952b1857689f0b88f31" + integrity sha512-PQe/5jN8wHr30d8422+2CV+XzbJTCFLGxzb0OrwbxrRiNdZA+FFXOqVak1vd3dqk4qogmmqEVQFkwQ4PNHzNgA== + dependencies: + debug "4.3.2" + mongodb-memory-server "6.9.6" + uuid "8.3.2" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1089,6 +1105,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== +"@types/node-fetch@^2.5.12": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-fetch@^2.5.4": version "2.6.2" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da" @@ -1107,16 +1131,31 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== +"@types/node@^14.6.4": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== + "@types/node@^16.11.46": version "16.11.46" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.46.tgz#26047602eefa47b36759d9ebb1b55ad08ce97a73" integrity sha512-x+sfpb2dMrhCQPL4NAGs64Z9hh0t72aP0dg+PuZidmPr/0Gj5ELQTjD/t46dq3DF/8ZvSHOaIyDIbAsdPshyVQ== +"@types/node@^16.4.6": + version "16.18.123" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.123.tgz#9073e454ee52ce9e2de038e7e0cf90f65c9abd56" + integrity sha512-/n7I6V/4agSpJtFDKKFEa763Hc1z3hmvchobHS1TisCOTKD5nxq8NJ2iK7SRIMYL276Q9mgWOx2AWp5n2XI6eA== + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/object-hash@^2.1.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-2.2.1.tgz#67c169f8f033e0b62abbf81df2d00f4598d540b9" + integrity sha512-i/rtaJFCsPljrZvP/akBqEwUP2y5cZLOmvO+JaYnz01aPknrQ+hB5MRcO7iqCUsFaYfTG8kGfKUyboA07xeDHQ== + "@types/prettier@^2.0.0": version "2.6.4" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.4.tgz#ad899dad022bab6b5a9f0a0fe67c2f7a4a8950ed" @@ -1127,6 +1166,11 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== +"@types/qs@^6.9.7": + version "6.9.17" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.17.tgz#fc560f60946d0aeff2f914eb41679659d3310e1a" + integrity sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ== + "@types/range-parser@*": version "1.2.4" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" @@ -1155,6 +1199,11 @@ resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1" integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ== +"@types/tmp@^0.2.0": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.6.tgz#d785ee90c52d7cc020e249c948c36f7b32d1e217" + integrity sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA== + "@types/uuid@^8.3.4": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" @@ -1528,6 +1577,13 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +async-mutex@^0.3.0: + version "0.3.2" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.3.2.tgz#1485eda5bda1b0ec7c8df1ac2e815757ad1831df" + integrity sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA== + dependencies: + tslib "^2.3.1" + async-retry@^1.2.1: version "1.3.3" resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" @@ -1571,6 +1627,13 @@ aws-sdk@^2.1174.0: uuid "8.0.0" xml2js "0.4.19" +axios@^0.20.0: + version "0.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.20.0.tgz#057ba30f04884694993a8cd07fa394cff11c50bd" + integrity sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA== + dependencies: + follow-redirects "^1.10.0" + axios@^0.21.1: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" @@ -1692,6 +1755,15 @@ bl@^2.2.0, bl@^2.2.1: readable-stream "^2.3.5" safe-buffer "^5.1.1" +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.5.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1787,6 +1859,11 @@ bson@^1.1.4: resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a" integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg== +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + buffer-equal-constant-time@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -1811,7 +1888,7 @@ buffer@4.9.2: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.6.0: +buffer@^5.5.0, buffer@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -1846,6 +1923,14 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +call-bind-apply-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz#32e5892e6361b29b0b545ba6f7763378daca2840" + integrity sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -1854,6 +1939,14 @@ call-bind@^1.0.0, call-bind@^1.0.2: function-bind "^1.1.1" get-intrinsic "^1.0.2" +call-bound@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.3.tgz#41cfd032b593e39176a71533ab4f384aa04fd681" + integrity sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA== + dependencies: + call-bind-apply-helpers "^1.0.1" + get-intrinsic "^1.2.6" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1864,7 +1957,7 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.0.0: +camelcase@^6.0.0, camelcase@^6.1.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -1979,11 +2072,32 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +cloudpayments@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/cloudpayments/-/cloudpayments-6.0.1.tgz#7859ce1c26f5b7794ef7b441cb1fa9bec7a05138" + integrity sha512-JxYTw8mY+K7i1a9s6T1E1Z7SglghRP2up3XDG5pwW3nQtvSekZbljEvqyDRIZSz2NSXoeRd//Zrd+b0xu22ogQ== + dependencies: + "@types/node" "^16.4.6" + "@types/node-fetch" "^2.5.12" + "@types/object-hash" "^2.1.1" + "@types/qs" "^6.9.7" + node-fetch "^2.6.0" + object-hash "^2.2.0" + qs "^6.10.1" + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +"codex-accounting-sdk@https://github.com/codex-team/codex-accounting-sdk.git": + version "1.2.5" + resolved "https://github.com/codex-team/codex-accounting-sdk.git#7de756db49aea72eebc7e45d8f9c458c9c2d8dce" + dependencies: + "@types/node" "^14.6.4" + axios "^0.20.0" + typescript "^3.7.5" + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -2048,6 +2162,11 @@ commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== + component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -2140,6 +2259,15 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.3: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + cssfilter@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" @@ -2195,6 +2323,13 @@ debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" +debug@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -2202,6 +2337,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2348,6 +2490,15 @@ dotenv@^16.0.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d" integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + dynamic-dedupe@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1" @@ -2392,7 +2543,7 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -end-of-stream@^1.1.0: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== @@ -2435,6 +2586,23 @@ es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19 string.prototype.trimstart "^1.0.5" unbox-primitive "^1.0.2" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -2867,6 +3035,13 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== + dependencies: + pend "~1.2.0" + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -2911,6 +3086,20 @@ finalhandler@1.2.0: statuses "2.0.1" unpipe "~1.0.0" +find-cache-dir@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-package-json@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/find-package-json/-/find-package-json-1.2.0.tgz#4057d1b943f82d8445fe52dc9cf456f6b8b58083" + integrity sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw== + find-up@^2.0.0, find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" @@ -2945,6 +3134,11 @@ fn-args@5.0.0: resolved "https://registry.yarnpkg.com/fn-args/-/fn-args-5.0.0.tgz#7a18e105c8fb3bf0a51c30389bf16c9ebe740bb3" integrity sha512-CtbfI3oFFc3nbdIoHycrfbrxiGgxXBXXuyOl49h47JawM1mYrqpiRqnH5CB2mBatdXvHHOUO6a+RiAuuvKt0lw== +follow-redirects@^1.10.0: + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + follow-redirects@^1.14.0, follow-redirects@^1.14.9: version "1.15.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" @@ -3002,6 +3196,11 @@ fs-capacitor@^6.2.0: resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-6.2.0.tgz#fa79ac6576629163cb84561995602d8999afb7f5" integrity sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" @@ -3034,6 +3233,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + function.prototype.name@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" @@ -3088,11 +3292,40 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.3" +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6: + version "1.2.7" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.7.tgz#dcfcb33d3272e15f445d15124bc0a216189b9044" + integrity sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + function-bind "^1.1.2" + get-proto "^1.0.0" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-port@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + +get-proto@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -3151,6 +3384,11 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" @@ -3222,6 +3460,11 @@ has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -3272,6 +3515,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -3394,7 +3644,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4354,6 +4604,13 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lockfile@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.4.tgz#07f819d25ae48f87e538e6578b6964a4981a5609" + integrity sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA== + dependencies: + signal-exit "^3.0.2" + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -4426,7 +4683,7 @@ lru-cache@^7.10.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.13.1.tgz#267a81fbd0881327c46a81c5922606a2cfe336c4" integrity sha512-CHqbAq7NFlW3RSnoWXLJBxCWaZVBrfa9UEHId2M3AW8iEBurbqduNexEUCGc3SHc6iCYXNJCDi903LajSVAEPQ== -make-dir@^3.0.0, make-dir@^3.1.0: +make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -4457,6 +4714,16 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +md5-file@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-5.0.0.tgz#e519f631feca9c39e7f9ea1780b63c4745012e20" + integrity sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -4592,6 +4859,68 @@ mkdirp@^0.5.1: dependencies: minimist "^1.2.6" +mongodb-memory-server-core@6.10.0: + version "6.10.0" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-6.10.0.tgz#9239c7941e5b0a225b50494563f0fc528c056690" + integrity sha512-Mil7M4w1231laVi3RYckVnvHANgSIHUICzdIxI5N2JM/i+uKamxkgUXmjWob188jWrWrTqeCI2vNq6KoGzRlxQ== + dependencies: + "@types/tmp" "^0.2.0" + async-mutex "^0.3.0" + camelcase "^6.1.0" + debug "^4.2.0" + find-cache-dir "^3.3.1" + find-package-json "^1.2.0" + get-port "^5.1.1" + https-proxy-agent "^5.0.0" + md5-file "^5.0.0" + mkdirp "^1.0.4" + mongodb "^3.6.9" + semver "^7.3.5" + tar-stream "^2.1.4" + tmp "^0.2.1" + tslib "^2.3.0" + uuid "^8.3.1" + yauzl "^2.10.0" + +mongodb-memory-server-core@6.9.6: + version "6.9.6" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-6.9.6.tgz#90ef0562bea675ef68bd687533792da02bcc81f3" + integrity sha512-ZcXHTI2TccH3L5N9JyAMGm8bbAsfLn8SUWOeYGHx/vDx7vu4qshyaNXTIxeHjpUQA29N+Z1LtTXA6vXjl1eg6w== + dependencies: + "@types/tmp" "^0.2.0" + camelcase "^6.0.0" + cross-spawn "^7.0.3" + debug "^4.2.0" + find-cache-dir "^3.3.1" + find-package-json "^1.2.0" + get-port "^5.1.1" + https-proxy-agent "^5.0.0" + lockfile "^1.0.4" + md5-file "^5.0.0" + mkdirp "^1.0.4" + semver "^7.3.2" + tar-stream "^2.1.4" + tmp "^0.2.1" + uuid "^8.3.0" + yauzl "^2.10.0" + optionalDependencies: + mongodb "^3.6.2" + +mongodb-memory-server@6.9.6: + version "6.9.6" + resolved "https://registry.yarnpkg.com/mongodb-memory-server/-/mongodb-memory-server-6.9.6.tgz#ced1a100f58363317a562efaf8821726c433cfd2" + integrity sha512-BjGPPh5f61lMueG7px9DneBIrRR/GoWUHDvLWVAXhQhKVcwMMXxgeEba6zdDolZHfYAu6aYGPzhOuYKIKPgpBQ== + dependencies: + mongodb-memory-server-core "6.9.6" + +mongodb-memory-server@^6.6.1: + version "6.10.0" + resolved "https://registry.yarnpkg.com/mongodb-memory-server/-/mongodb-memory-server-6.10.0.tgz#3011f12b69bd5cd3610eb51df57555bdab5383cb" + integrity sha512-u/n35Jdbl6CwlOlpFcCkMcVsckJNhKsldI2ImePe4+5e/kKgyktS97K5VBv5wppTVOblAbebFInnIsvSts74nQ== + dependencies: + mongodb-memory-server-core "6.10.0" + tslib "^2.3.0" + mongodb@3.5.9: version "3.5.9" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.5.9.tgz#799b72be8110b7e71a882bb7ce0d84d05429f772" @@ -4605,6 +4934,19 @@ mongodb@3.5.9: optionalDependencies: saslprep "^1.0.0" +mongodb@^3.6.2, mongodb@^3.6.9: + version "3.7.4" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.7.4.tgz#119530d826361c3e12ac409b769796d6977037a4" + integrity sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw== + dependencies: + bl "^2.2.1" + bson "^1.1.4" + denque "^1.4.1" + optional-require "^1.1.8" + safe-buffer "^5.1.2" + optionalDependencies: + saslprep "^1.0.0" + mongodb@^3.7.3: version "3.7.3" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.7.3.tgz#b7949cfd0adc4cc7d32d3f2034214d4475f175a5" @@ -4628,7 +4970,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -4675,6 +5017,13 @@ node-addon-api@^5.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.0.0.tgz#7d7e6f9ef89043befdb20c1989c905ebde18c501" integrity sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA== +node-fetch@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -4799,11 +5148,21 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + object-inspect@^1.12.0, object-inspect@^1.9.0: version "1.12.2" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== +object-inspect@^1.13.3: + version "1.13.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" + integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -5026,6 +5385,11 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -5046,7 +5410,7 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== -pkg-dir@^4.2.0: +pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -5139,6 +5503,13 @@ qs@6.10.3: dependencies: side-channel "^1.0.4" +qs@^6.10.1: + version "6.13.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.1.tgz#3ce5fc72bd3a8171b85c99b93c65dd20b7d1b16e" + integrity sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg== + dependencies: + side-channel "^1.0.6" + querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" @@ -5228,6 +5599,15 @@ readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.1.1, readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -5575,6 +5955,35 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -5584,6 +5993,17 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -5913,6 +6333,17 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + tar@^6.1.11: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" @@ -5964,6 +6395,11 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" +tmp@^0.2.1: + version "0.2.3" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" + integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -6125,6 +6561,11 @@ tslib@^2.1.0, tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslib@^2.3.0, tslib@^2.3.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@~2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" @@ -6179,6 +6620,11 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" +typescript@^3.7.5: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + typescript@^4.7.4: version "4.7.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" @@ -6305,7 +6751,7 @@ uuid@8.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== -uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: +uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.1, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== @@ -6583,6 +7029,14 @@ yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"