-
Notifications
You must be signed in to change notification settings - Fork 2
feat(billing): implement promo code functionality #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 9 commits
9d1f51a
d03e093
fb4e419
e85c91e
0aa6738
72a6e60
377fa6f
e926076
4c25dc0
515a9e8
94001e8
5b2f68a
0b74706
1197be9
210de9f
cad0777
2f8e5b5
be4f3b2
f5b405f
10f578e
7945cdb
d782e2b
e9ed561
b0ef27a
627f8fb
cf31f71
e0af65c
5693467
158f2ef
02d13a2
910ac27
2e5ad23
36af36d
9c2e51e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |
| import cloudPaymentsApi from '../utils/cloudPaymentsApi'; | ||
| import PlanModel from '../models/plan'; | ||
| import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments'; | ||
| import PromoCodeService from '../utils/promoCodeService'; | ||
|
|
||
| const PENNY_MULTIPLIER = 100; | ||
|
|
||
|
|
@@ -88,7 +89,7 @@ | |
| return router; | ||
| } | ||
|
|
||
| /** | ||
|
Check warning on line 92 in src/billing/cloudpayments.ts
|
||
| * Generates invoice id for payment | ||
| * | ||
| * @param tariffPlan - tariff plan to generate invoice id | ||
|
|
@@ -141,7 +142,7 @@ | |
|
|
||
| let workspace: WorkspaceModel; | ||
| let member: ConfirmedMemberDBScheme; | ||
| let plan: PlanDBScheme; | ||
| let plan: PlanModel; | ||
| let planId: string; | ||
|
|
||
| const { workspaceId, userId, tariffPlanId } = data; | ||
|
|
@@ -161,11 +162,41 @@ | |
|
|
||
| const recurrentPaymentSettings = data.cloudPayments?.recurrent; | ||
|
|
||
| if (data.promo && !data.isCardLinkOperation) { | ||
| try { | ||
| const promoCodeService = new PromoCodeService(context.factories); | ||
| const promoPricing = await promoCodeService.getPricingForPromoCodeId( | ||
| data.promo.id, | ||
| data.userId, | ||
| data.workspaceId, | ||
| plan | ||
| ); | ||
|
|
||
| if ( | ||
| promoPricing.benefitType !== data.promo.benefitType || | ||
| promoPricing.finalAmount !== data.promo.finalAmount || | ||
| promoPricing.originalAmount !== data.promo.originalAmount || | ||
| promoPricing.discountAmount !== data.promo.discountAmount | ||
| ) { | ||
| this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo code payment data does not match current promo calculation', body); | ||
|
|
||
| return; | ||
| } | ||
| } catch (e) { | ||
| const error = e as Error; | ||
|
|
||
| this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo code is invalid: ${error.toString()}`, body); | ||
|
|
||
| return; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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?.finalAmount ?? plan.monthlyCharge; | ||
| const isRightAmount = +body.Amount === expectedAmount || (!data.promo?.finalAmount && recurrentPaymentSettings?.startDate); | ||
|
|
||
| if (!isRightAmount) { | ||
| this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body); | ||
|
|
@@ -205,7 +236,7 @@ | |
| 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, | ||
|
|
@@ -295,6 +326,28 @@ | |
| if (subscriptionId) { | ||
| await workspace.setSubscriptionId(subscriptionId); | ||
| } | ||
|
|
||
| if (data.promo && !data.isCardLinkOperation) { | ||
| const promoCodeService = new PromoCodeService(req.context.factories); | ||
| const promoPricing = await promoCodeService.getPricingForPromoCodeId( | ||
| data.promo.id, | ||
| data.userId, | ||
| data.workspaceId, | ||
| tariffPlan | ||
| ); | ||
|
|
||
| await promoCodeService.createUsage({ | ||
| promoCode: promoPricing.promoCode, | ||
| userId: data.userId, | ||
| workspaceId: workspace._id, | ||
| planId: tariffPlan._id, | ||
| benefitType: data.promo.benefitType, | ||
| originalAmount: data.promo.originalAmount, | ||
| finalAmount: data.promo.finalAmount, | ||
| discountAmount: data.promo.discountAmount, | ||
| utm: data.promo.utm, | ||
| }); | ||
| } | ||
|
|
||
| } catch (e) { | ||
| const error = e as Error; | ||
|
|
||
|
|
@@ -442,7 +495,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?.finalAmount ?? tariffPlan.monthlyCharge); | ||
|
|
||
| let messageText = ''; | ||
|
|
||
|
|
@@ -555,7 +608,7 @@ | |
|
|
||
| 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, | ||
|
|
@@ -737,7 +790,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 { | ||
| res.json({ | ||
| code: errorCode, | ||
| }); | ||
|
|
@@ -802,7 +855,7 @@ | |
| promise.catch(e => console.error('Error while sending message to Telegram: ' + e)); | ||
| } | ||
|
|
||
| /** | ||
|
Check warning on line 858 in src/billing/cloudpayments.ts
|
||
| * Parses body and returns card data | ||
| * @param request - request body to parse | ||
| */ | ||
|
|
@@ -826,8 +879,9 @@ | |
| * @param workspace - workspace for which payment is made | ||
| * @param tariff - paid tariff plan | ||
| * @param userMail - user email address | ||
| * @param amount - actual paid amount | ||
| */ | ||
| 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 | ||
|
|
@@ -836,9 +890,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, | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,10 @@ | ||
| import type { PromoCodeBenefitType, Utm } from '@hawk.so/types'; | ||
|
|
||
| /** | ||
| * Promo benefit types that can be applied during payment. | ||
| */ | ||
| export type PaymentPromoBenefitType = Exclude<PromoCodeBenefitType, 'grant_plan'>; | ||
|
|
||
| /** | ||
| * Data for setting up recurring payments | ||
| */ | ||
|
|
@@ -35,6 +42,41 @@ interface CloudPaymentsSettings { | |
| recurrent: RecurrentPaymentSettings; | ||
| } | ||
|
|
||
| /** | ||
| * Promo data attached to payment request | ||
| */ | ||
| export interface PaymentPromoData { | ||
| /** | ||
| * Applied promo code id | ||
| */ | ||
| id: string; | ||
|
|
||
| /** | ||
| * Promo benefit type | ||
| */ | ||
| benefitType: PaymentPromoBenefitType; | ||
|
|
||
| /** | ||
| * Plan price before promo | ||
| */ | ||
| originalAmount: number; | ||
|
|
||
| /** | ||
| * Final price after promo | ||
| */ | ||
| finalAmount: number; | ||
|
|
||
| /** | ||
| * Actual discount amount | ||
| */ | ||
| discountAmount: number; | ||
|
|
||
| /** | ||
| * UTM parameters captured when promo was applied | ||
| */ | ||
| utm?: Utm; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can't rely on these data, because it can be changed by user. It's better to pass just promo.id and resolve these values from the db |
||
| } | ||
|
|
||
| export interface PaymentData { | ||
| /** | ||
| * Data for Cloudpayments needs | ||
|
|
@@ -56,6 +98,10 @@ export interface PaymentData { | |
| * If true, we will save user card | ||
| */ | ||
| shouldSaveCard: boolean; | ||
| /** | ||
| * Applied promo code data | ||
| */ | ||
| promo?: PaymentPromoData; | ||
| /** | ||
| * True if this is card linking operation – charging minimal amount of money to validate card info | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { Collection, ObjectId } from 'mongodb'; | ||
| import AbstractModel from './abstractModel'; | ||
| import { | ||
| PromoCodeBenefit, | ||
| PromoCodeDBScheme | ||
| } from '@hawk.so/types'; | ||
|
|
||
| /** | ||
| * Model representing promo code settings. | ||
| */ | ||
| export default class PromoCodeModel extends AbstractModel<PromoCodeDBScheme> implements PromoCodeDBScheme { | ||
| /** | ||
| * Promo code id. | ||
| */ | ||
| public _id!: ObjectId; | ||
|
|
||
| /** | ||
| * Normalized promo code value. | ||
| */ | ||
| public value!: string; | ||
|
|
||
| /** | ||
| * Benefit granted by this promo code. | ||
| */ | ||
| public benefit!: PromoCodeBenefit; | ||
|
|
||
| /** | ||
| * Maximum successful usages count. | ||
| */ | ||
| public limit?: number; | ||
|
|
||
| /** | ||
| * Expiration date. | ||
| */ | ||
| public expiresAt?: Date; | ||
|
|
||
| /** | ||
| * Creation date. | ||
| */ | ||
| public createdAt!: Date; | ||
|
|
||
| /** | ||
| * Last update date. | ||
| */ | ||
| public updatedAt!: Date; | ||
|
|
||
| /** | ||
| * Creator id. | ||
| */ | ||
| public createdBy!: string; | ||
|
|
||
| /** | ||
| * Model's collection. | ||
| */ | ||
| protected collection: Collection<PromoCodeDBScheme>; | ||
|
|
||
| /** | ||
| * Create PromoCode instance. | ||
| * | ||
| * @param promoCodeData - promo code data | ||
| */ | ||
| constructor(promoCodeData: PromoCodeDBScheme) { | ||
| super(promoCodeData); | ||
| this.collection = this.dbConnection.collection<PromoCodeDBScheme>('promoCodes'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { Collection, ObjectId } from 'mongodb'; | ||
| import AbstractModel from './abstractModel'; | ||
| import { | ||
| PromoCodeBenefitType, | ||
| PromoCodeUsageDBScheme | ||
| } from '@hawk.so/types'; | ||
|
|
||
| /** | ||
| * Model representing successful promo code application. | ||
| */ | ||
| export default class PromoCodeUsageModel extends AbstractModel<PromoCodeUsageDBScheme> implements PromoCodeUsageDBScheme { | ||
| /** | ||
| * Promo code usage id. | ||
| */ | ||
| public _id!: ObjectId; | ||
|
|
||
| /** | ||
| * Applied promo code id. | ||
| */ | ||
| public promoCodeId!: ObjectId; | ||
|
|
||
| /** | ||
| * User who applied promo code. | ||
| */ | ||
| public userId!: string; | ||
|
|
||
| /** | ||
| * Workspace where promo code was applied. | ||
| */ | ||
| public workspaceId!: ObjectId; | ||
|
|
||
| /** | ||
| * Plan to which promo was applied. | ||
| */ | ||
| public planId?: ObjectId; | ||
|
|
||
| /** | ||
| * Benefit type at application time. | ||
| */ | ||
| public benefitType!: PromoCodeBenefitType; | ||
|
|
||
| /** | ||
| * Price before promo. | ||
| */ | ||
| public originalAmount?: number; | ||
|
|
||
| /** | ||
| * Price after promo. | ||
| */ | ||
| public finalAmount?: number; | ||
|
|
||
| /** | ||
| * Actual discount amount. | ||
| */ | ||
| public discountAmount?: number; | ||
|
|
||
| /** | ||
| * UTM parameters captured on apply. | ||
| */ | ||
| public utm?: PromoCodeUsageDBScheme['utm']; | ||
|
|
||
| /** | ||
| * Application date. | ||
| */ | ||
| public appliedAt!: Date; | ||
|
|
||
| /** | ||
| * Model's collection. | ||
| */ | ||
| protected collection: Collection<PromoCodeUsageDBScheme>; | ||
|
|
||
| /** | ||
| * Create PromoCodeUsage instance. | ||
| * | ||
| * @param usageData - usage data | ||
| */ | ||
| constructor(usageData: PromoCodeUsageDBScheme) { | ||
| super(usageData); | ||
| this.collection = this.dbConnection.collection<PromoCodeUsageDBScheme>('promoCodeUsages'); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
explain cases in jsdoc