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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions migrations/20261013000000-create-promo-code-collections.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Create promo code collections and prevent repeated use by a user or workspace.
* MongoDB creates the collections when their indexes are created.
*/
module.exports = {
/**
* Create unique indexes for promo codes and successful redemptions.
*
* @param {Db} db - MongoDB database receiving the promo code indexes.
* @returns {Promise<void>} Resolves when all indexes have been created.
*/
async up(db) {
await db.collection('promoCodes').createIndex({
code: 1,
}, {
name: 'promoCode',
unique: true,
});

await db.collection('promoCodeRedemptions').createIndex({
promoCodeId: 1,
userId: 1,
}, {
name: 'promoCodeUser',
unique: true,
});

await db.collection('promoCodeRedemptions').createIndex({
promoCodeId: 1,
workspaceId: 1,
}, {
name: 'promoCodeWorkspace',
unique: true,
});
},

/**
* Remove the promo code indexes while preserving collection data.
*
* @param {Db} db - MongoDB database containing the promo code indexes.
* @returns {Promise<void>} Resolves when all indexes have been removed.
*/
async down(db) {
await db.collection('promoCodeRedemptions').dropIndex('promoCodeWorkspace');
await db.collection('promoCodeRedemptions').dropIndex('promoCodeUser');
await db.collection('promoCodes').dropIndex('promoCode');
},
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.6.0",
"version": "1.6.1",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
125 changes: 106 additions & 19 deletions src/billing/cloudpayments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from 'express';
import { PromoPayment, validatePromoOperation, validatePromoSubscription } from './promoCodes/redemptions';
import * as telegram from '../utils/telegram';
import { TelegramBotURLs } from '../utils/telegram';
import {
Expand All @@ -22,8 +23,7 @@
BusinessOperationType,
ConfirmedMemberDBScheme,
PayloadOfWorkspacePlanPurchase,
PlanDBScheme,
PlanProlongationPayload
PlanDBScheme
} from '@hawk.so/types';
import WorkspaceModel from '../models/workspace';
import HawkCatcher from '@hawk.so/nodejs';
Expand All @@ -49,6 +49,9 @@
* Class for describing the logic of payment routes
*/
export default class CloudPaymentsWebhooks {
/**
* Authenticated CloudPayments client used for subscriptions and receipts.
*/
private readonly clientService = new ClientService({
publicId: process.env.CLOUDPAYMENTS_PUBLIC_ID || '',
privateKey: process.env.CLOUDPAYMENTS_SECRET || '',
Expand Down Expand Up @@ -93,6 +96,7 @@
*
* @param tariffPlan - tariff plan to generate invoice id
* @param workspace - workspace data to generate invoice id
* @returns human-readable invoice label for the payment widget
*/
private generateInvoiceId(tariffPlan: PlanDBScheme, workspace: WorkspaceModel): string {
const now = new Date();
Expand Down Expand Up @@ -124,7 +128,9 @@
return;
}

/** Data validation */
/**
* Validate the identity required for a card link or a plan purchase.
*/
if (data.isCardLinkOperation) {
if (!data.userId || !data.workspaceId) {
this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, '[Billing / Check] There is no necessary data in the card linking request', req.body);
Expand Down Expand Up @@ -165,14 +171,26 @@
* 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;
const expectedAmount = data.promo ? data.promo.finalAmount : plan.monthlyCharge;
const isRightAmount = +body.Amount === expectedAmount || (!data.promo && recurrentPaymentSettings?.startDate);

if (!isRightAmount) {
this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body);

return;
}

if (data.promo) {
try {
validatePromoSubscription(data, plan.monthlyCharge, Boolean(workspace.isDebug));
await context.factories.promoCodesFactory.ensurePaymentAvailable(this.buildPromoPayment(body, data));
} catch (error) {
this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] ${String(error)}`, body);

return;
}
}

/**
* Create business operation about creation of subscription
*/
Expand All @@ -185,7 +203,7 @@
workspaceId: workspace._id,
amount: +body.Amount * PENNY_MULTIPLIER,
currency: body.Currency,
userId: member._id,
userId: data.promo ? member.userId : member._id,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the same field means different things depending on the promo code: for regular payments it's the team membership _id (as in master), for promo payments it's the user id.

In this repo, userId of a plan purchase is read only by the new validatePromoOperation(). I'd keep member._id for all payments and drop the userId comparison there: transactionId, workspace, plan and amount are enough to match the operation, and the redemption already gets the user id from the signed checksum. If we want to store the real user id, let's change it for all payments in a separate PR.

tariffPlanId: plan._id,
},
dtCreated: new Date(),
Expand All @@ -205,7 +223,7 @@
telegram.sendMessage(`🤗 [Billing / Check] All checks passed successfully ${this.formatWorkspaceForTelegram(workspace)}`, TelegramBotURLs.Money)
.catch(e => console.error('Error while sending message to Telegram: ' + e));

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

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

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: CheckCodes.SUCCESS,
Expand All @@ -226,7 +244,7 @@
console.log('💎 CloudPayments /pay request', body);

try {
data = await this.getDataFromRequest(req);
data = await this.getDataFromRequest(req, true);
} catch (e) {
const error = e as Error;

Expand All @@ -235,7 +253,9 @@
return;
}

/** Data validation */
/**
* Validate the identity required for a card link or a plan purchase.
*/
if (data.isCardLinkOperation) {
if (!data.userId || !data.workspaceId) {
this.sendError(res, PayCodes.SUCCESS, '[Billing / Pay] No workspace or user id in request body', req.body);
Expand Down Expand Up @@ -270,6 +290,27 @@
return;
}

if (data.promo) {
let payment: PromoPayment;

try {
payment = this.buildPromoPayment(body, data);
validatePromoOperation(payment, businessOperation);
} catch (error) {
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Invalid promo payment: ${String(error)}`, body);

return;
}

try {
await req.context.factories.promoCodeRedemptionsFactory.redeem(payment);
} catch (error) {
this.sendError(res.status(500), PayCodes.RETRY, `[Billing / Pay] Could not redeem promo code: ${String(error)}`, body);

return;
}
}

try {
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);

Expand Down Expand Up @@ -303,7 +344,9 @@
return;
}

// let accountId = workspace.accountId;
/*
* let accountId = workspace.accountId;
*/

/*
* try {
Expand Down Expand Up @@ -335,7 +378,9 @@
* const error = e as Error;
*/

// this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body);
/*
* this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Error while creating operations in accounting ${error.toString()}`, body);
*/

/*
* return;
Expand All @@ -361,7 +406,9 @@
}

try {
// todo: add plan-prolongation notification if it was a payment by subscription
/**
* @todo Add plan-prolongation notification for a subscription payment.
*/
const senderWorkerTask: PaymentSuccessNotificationTask = {
type: SenderWorkerTaskType.PaymentSuccess,
payload: {
Expand Down Expand Up @@ -442,7 +489,7 @@
*/
const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined;

await this.sendReceipt(workspace, tariffPlan, userEmail);
await this.sendReceipt(workspace, tariffPlan, userEmail, data.promo ? +body.Amount : tariffPlan.monthlyCharge);

let messageText = '';

Expand Down Expand Up @@ -487,14 +534,14 @@
*/
private async fail(req: express.Request, res: express.Response): Promise<void> {
const body: FailRequest = req.body;
let data: PlanProlongationPayload;
let data: PaymentData;

console.log('💎 CloudPayments /fail request', body);

const failReasonLine = `reason: ${body.Reason} (${body.ReasonCode})`;

try {
data = await this.getDataFromRequest(req);
data = await this.getDataFromRequest(req, true);
} catch (e) {
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request\n${failReasonLine}`, body);

Expand Down Expand Up @@ -563,7 +610,7 @@
subscription id: ${body.SubscriptionId || 'none'}`
, TelegramBotURLs.Money));

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

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

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: FailCodes.SUCCESS,
Expand Down Expand Up @@ -657,6 +704,7 @@
* Formats workspace identity for Telegram money notifications
*
* @param workspace - workspace model or null when it was not found
* @returns workspace name and identifier, or a fallback for missing data
*/
private formatWorkspaceForTelegram(workspace: Pick<WorkspaceModel, '_id' | 'name'> | null): string {
if (!workspace) {
Expand All @@ -675,6 +723,7 @@
*
* @param req - express request
* @param workspaceId - id of workspace
* @returns existing workspace model
*/
private async getWorkspace(req: express.Request, workspaceId: string): Promise<WorkspaceModel> {
const workspace = await req.context.factories.workspacesFactory.findById(workspaceId);
Expand All @@ -691,6 +740,7 @@
*
* @param req - express request
* @param userId - id of user to fetch
* @returns existing payer model
*/
private async getUser(req: express.Request, userId: string): Promise<UserModel> {
const user = await req.context.factories.usersFactory.findById(userId);
Expand All @@ -707,6 +757,7 @@
*
* @param req - express request
* @param transactionId - id of the transaction for fetching business operation
* @returns existing business operation for the provider transaction
*/
private async getBusinessOperation(req: express.Request, transactionId: string): Promise<BusinessOperationModel> {
const businessOperation = await req.context.factories.businessOperationsFactory.getBusinessOperationByTransactionId(transactionId);
Expand All @@ -723,6 +774,7 @@
*
* @param userId - id of current user
* @param workspace - workspace data
* @returns confirmed workspace administrator allowed to pay
*/
private async getMember(userId: string, workspace: WorkspaceModel): Promise<ConfirmedMemberDBScheme> {
const user = await workspace.getMemberInfo(userId);
Expand All @@ -747,6 +799,7 @@
*
* @param req - express request
* @param tariffPlanId - plan id
* @returns existing purchased plan model
*/
private async getPlan(req: express.Request, tariffPlanId: string): Promise<PlanModel> {
const plan = await req.context.factories.plansFactory.findById(tariffPlanId);
Expand All @@ -766,7 +819,7 @@
* @param errorText - error description
* @param backtrace - request data and error data
*/
private sendError(res: express.Response, errorCode: CheckCodes | PayCodes | FailCodes | RecurrentCodes, errorText: string, backtrace: { [key: string]: any }): void {

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

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type
res.json({
code: errorCode,
});
Expand All @@ -779,12 +832,37 @@
HawkCatcher.send(new Error(errorText), backtrace);
}

/**
* Map signed promo data and provider fields to a payment for domain validation.
*
* @param body - provider callback
* @param data - verified payment identity
* @returns promo payment without HTTP context
*/
private buildPromoPayment(body: CheckRequest | PayRequest, data: PaymentData): PromoPayment {
if (!data.promo || data.isCardLinkOperation) {
throw new Error('PROMO_CODE_INVALID_PAYMENT');
}

return {
...data.promo,
workspaceId: data.workspaceId,
userId: data.userId,
tariffPlanId: data.tariffPlanId,
transactionId: body.TransactionId?.toString() || '',
paidAmount: Number(body.Amount),
currency: body.Currency,
};
}

/**
* Parses request body and returns data from it
*
* @param req - request with necessary data
* @param allowExpiredPromo - accept a delayed completion callback for a promo payment
* @returns signed payment data or identity resolved from an existing subscription
*/
private async getDataFromRequest(req: express.Request): Promise<PaymentData> {
private async getDataFromRequest(req: express.Request, allowExpiredPromo = false): Promise<PaymentData> {
const context = req.context;
const body: CheckRequest | PayRequest | FailRequest = req.body;

Expand All @@ -794,10 +872,17 @@
*/
if (body.Data) {
const parsedData = JSON.parse(body.Data || '{}') as WebhookData;
const verifiedData = checksumService.parseAndVerifyChecksum(parsedData.checksum, allowExpiredPromo);

/*
* Only subscription settings come from the widget; identity and price must remain signed.
*/
return {
...checksumService.parseAndVerifyChecksum(parsedData.checksum),
...parsedData,
tariffPlanId: '',
shouldSaveCard: false,
isCardLinkOperation: false,
...verifiedData,
cloudPayments: parsedData.cloudPayments,
};
}

Expand Down Expand Up @@ -834,6 +919,7 @@
/**
* Parses body and returns card data
* @param request - request body to parse
* @returns reusable card details, or null when any required field is missing
*/
private getCardData(request: PayRequest): Omit<BankCard, 'id'> | null {
if (!request.CardType || !request.CardExpDate || !request.CardLastFour || !request.CardFirstSix || !request.Token) {
Expand All @@ -855,8 +941,9 @@
* @param workspace - workspace for which payment is made
* @param tariff - paid tariff plan
* @param userMail - user email address
* @param amount - actual payment amount in rubles
*/
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string): Promise<void> {
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string, amount = tariff.monthlyCharge): Promise<void> {
/**
* A general tax that applies to all commercial activities
* involving the production and distribution of goods and the provision of services
Expand All @@ -865,9 +952,9 @@
const VALUE_ADDED_TAX = 0;

const item: CustomerReceiptItem = {
amount: tariff.monthlyCharge,
amount,
label: `${tariff.name} tariff plan`,
price: tariff.monthlyCharge,
price: amount,
vat: VALUE_ADDED_TAX,
quantity: 1,
};
Expand Down
Loading
Loading