Skip to content

Commit 9d1f51a

Browse files
committed
feat(billing): implement promo code functionality and update related types
1 parent 100f69b commit 9d1f51a

16 files changed

Lines changed: 1500 additions & 17 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"@graphql-tools/schema": "^8.5.1",
4343
"@graphql-tools/utils": "^8.9.0",
4444
"@hawk.so/nodejs": "^3.3.2",
45-
"@hawk.so/types": "^0.5.9",
45+
"@hawk.so/types": "^0.6.2",
4646
"@n1ru4l/json-patch-plus": "^0.2.0",
4747
"@node-saml/node-saml": "^5.0.1",
4848
"@octokit/oauth-methods": "^4.0.0",

src/billing/cloudpayments.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { PaymentData } from './types/paymentData';
4242
import cloudPaymentsApi from '../utils/cloudPaymentsApi';
4343
import PlanModel from '../models/plan';
4444
import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments';
45+
import PromoCodeService from '../utils/promoCodeService';
4546

4647
const PENNY_MULTIPLIER = 100;
4748

@@ -141,7 +142,7 @@ export default class CloudPaymentsWebhooks {
141142

142143
let workspace: WorkspaceModel;
143144
let member: ConfirmedMemberDBScheme;
144-
let plan: PlanDBScheme;
145+
let plan: PlanModel;
145146
let planId: string;
146147

147148
const { workspaceId, userId, tariffPlanId } = data;
@@ -161,11 +162,36 @@ export default class CloudPaymentsWebhooks {
161162

162163
const recurrentPaymentSettings = data.cloudPayments?.recurrent;
163164

165+
if (data.promoCodeValue && !data.isCardLinkOperation) {
166+
try {
167+
const promoCodeService = new PromoCodeService(context.factories);
168+
const promoPricing = await promoCodeService.getPricingForPlan(data.promoCodeValue, data.userId, data.workspaceId, plan);
169+
170+
if (
171+
promoPricing.promoCode._id.toString() !== data.promoCodeId ||
172+
promoPricing.finalAmount !== data.finalAmount ||
173+
promoPricing.originalAmount !== data.originalAmount ||
174+
promoPricing.discountAmount !== data.discountAmount
175+
) {
176+
this.sendError(res, CheckCodes.WRONG_AMOUNT, '[Billing / Check] Promo code payment data does not match current promo calculation', body);
177+
178+
return;
179+
}
180+
} catch (e) {
181+
const error = e as Error;
182+
183+
this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo code is invalid: ${error.toString()}`, body);
184+
185+
return;
186+
}
187+
}
188+
164189
/**
165190
* The amount will be considered correct if it is equal to the cost of the tariff plan.
166191
* Also, the cost will be correct if it is a payment to activate the subscription.
167192
*/
168-
const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate;
193+
const expectedAmount = data.finalAmount ?? plan.monthlyCharge;
194+
const isRightAmount = +body.Amount === expectedAmount || (!data.finalAmount && recurrentPaymentSettings?.startDate);
169195

170196
if (!isRightAmount) {
171197
this.sendError(res, CheckCodes.WRONG_AMOUNT, `[Billing / Check] Amount does not equal to plan monthly charge`, body);
@@ -295,6 +321,23 @@ export default class CloudPaymentsWebhooks {
295321
if (subscriptionId) {
296322
await workspace.setSubscriptionId(subscriptionId);
297323
}
324+
325+
if (data.promoCodeValue && !data.isCardLinkOperation && data.benefitType) {
326+
const promoCodeService = new PromoCodeService(req.context.factories);
327+
const promoCode = await promoCodeService.getValidPromoCode(data.promoCodeValue, data.userId, data.workspaceId);
328+
329+
await promoCodeService.createUsage({
330+
promoCode,
331+
userId: data.userId,
332+
workspaceId: workspace._id,
333+
planId: tariffPlan._id,
334+
benefitType: data.benefitType,
335+
originalAmount: data.originalAmount,
336+
finalAmount: data.finalAmount,
337+
discountAmount: data.discountAmount,
338+
utm: data.promoUtm,
339+
});
340+
}
298341
} catch (e) {
299342
const error = e as Error;
300343

@@ -442,7 +485,7 @@ plan monthly charge: ${data.cloudPayments?.recurrent.amount} ${body.Currency}`
442485
*/
443486
const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined;
444487

445-
await this.sendReceipt(workspace, tariffPlan, userEmail);
488+
await this.sendReceipt(workspace, tariffPlan, userEmail, data.finalAmount ?? tariffPlan.monthlyCharge);
446489

447490
let messageText = '';
448491

@@ -826,8 +869,9 @@ status: ${body.Status}`
826869
* @param workspace - workspace for which payment is made
827870
* @param tariff - paid tariff plan
828871
* @param userMail - user email address
872+
* @param amount - actual paid amount
829873
*/
830-
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string): Promise<void> {
874+
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string, amount = tariff.monthlyCharge): Promise<void> {
831875
/**
832876
* A general tax that applies to all commercial activities
833877
* involving the production and distribution of goods and the provision of services
@@ -836,9 +880,9 @@ status: ${body.Status}`
836880
const VALUE_ADDED_TAX = 0;
837881

838882
const item: CustomerReceiptItem = {
839-
amount: tariff.monthlyCharge,
883+
amount,
840884
label: `${tariff.name} tariff plan`,
841-
price: tariff.monthlyCharge,
885+
price: amount,
842886
vat: VALUE_ADDED_TAX,
843887
quantity: 1,
844888
};

src/billing/types/paymentData.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ export interface PaymentData {
5656
* If true, we will save user card
5757
*/
5858
shouldSaveCard: boolean;
59+
/**
60+
* Applied promo code id
61+
*/
62+
promoCodeId?: string;
63+
/**
64+
* Applied promo code value
65+
*/
66+
promoCodeValue?: string;
67+
/**
68+
* Promo benefit type
69+
*/
70+
benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price';
71+
/**
72+
* Plan price before promo
73+
*/
74+
originalAmount?: number;
75+
/**
76+
* Final price after promo
77+
*/
78+
finalAmount?: number;
79+
/**
80+
* Actual discount amount
81+
*/
82+
discountAmount?: number;
83+
/**
84+
* UTM parameters captured when promo was applied
85+
*/
86+
promoUtm?: {
87+
source?: string;
88+
medium?: string;
89+
campaign?: string;
90+
content?: string;
91+
term?: string;
92+
};
5993
/**
6094
* True if this is card linking operation – charging minimal amount of money to validate card info
6195
*/

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import ReleasesFactory from './models/releasesFactory';
3232
import RedisHelper from './redisHelper';
3333
import { appendSsoRoutes } from './sso';
3434
import { appendGitHubRoutes } from './integrations/github';
35+
import PromoCodesFactory from './models/promoCodesFactory';
36+
import PromoCodeUsagesFactory from './models/promoCodeUsagesFactory';
3537

3638
/**
3739
* Option to enable playground
@@ -172,13 +174,21 @@ class HawkAPI {
172174
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
173175
const releasesFactory = new ReleasesFactory(mongo.databases.events!);
174176

177+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
178+
const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!);
179+
180+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
181+
const promoCodeUsagesFactory = new PromoCodeUsagesFactory(mongo.databases.hawk!);
182+
175183
return {
176184
usersFactory,
177185
workspacesFactory,
178186
projectsFactory,
179187
plansFactory,
180188
businessOperationsFactory,
181189
releasesFactory,
190+
promoCodesFactory,
191+
promoCodeUsagesFactory,
182192
};
183193
}
184194

src/models/promoCode.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Collection, ObjectId } from 'mongodb';
2+
import AbstractModel from './abstractModel';
3+
import {
4+
PromoCodeBenefit,
5+
PromoCodeDBScheme
6+
} from '@hawk.so/types';
7+
8+
/**
9+
* Model representing promo code settings.
10+
*/
11+
export default class PromoCodeModel extends AbstractModel<PromoCodeDBScheme> implements PromoCodeDBScheme {
12+
/**
13+
* Promo code id.
14+
*/
15+
public _id!: ObjectId;
16+
17+
/**
18+
* Normalized promo code value.
19+
*/
20+
public value!: string;
21+
22+
/**
23+
* Benefit granted by this promo code.
24+
*/
25+
public benefit!: PromoCodeBenefit;
26+
27+
/**
28+
* Maximum successful usages count.
29+
*/
30+
public limit?: number;
31+
32+
/**
33+
* Expiration date.
34+
*/
35+
public expiresAt?: Date;
36+
37+
/**
38+
* Creation date.
39+
*/
40+
public createdAt!: Date;
41+
42+
/**
43+
* Last update date.
44+
*/
45+
public updatedAt!: Date;
46+
47+
/**
48+
* Creator id.
49+
*/
50+
public createdBy!: string;
51+
52+
/**
53+
* Model's collection.
54+
*/
55+
protected collection: Collection<PromoCodeDBScheme>;
56+
57+
/**
58+
* Create PromoCode instance.
59+
*
60+
* @param promoCodeData - promo code data
61+
*/
62+
constructor(promoCodeData: PromoCodeDBScheme) {
63+
super(promoCodeData);
64+
this.collection = this.dbConnection.collection<PromoCodeDBScheme>('promoCodes');
65+
}
66+
}

src/models/promoCodeUsage.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Collection, ObjectId } from 'mongodb';
2+
import AbstractModel from './abstractModel';
3+
import {
4+
PromoCodeBenefitType,
5+
PromoCodeUsageDBScheme
6+
} from '@hawk.so/types';
7+
8+
/**
9+
* Model representing successful promo code application.
10+
*/
11+
export default class PromoCodeUsageModel extends AbstractModel<PromoCodeUsageDBScheme> implements PromoCodeUsageDBScheme {
12+
/**
13+
* Promo code usage id.
14+
*/
15+
public _id!: ObjectId;
16+
17+
/**
18+
* Applied promo code id.
19+
*/
20+
public promoCodeId!: ObjectId;
21+
22+
/**
23+
* User who applied promo code.
24+
*/
25+
public userId!: string;
26+
27+
/**
28+
* Workspace where promo code was applied.
29+
*/
30+
public workspaceId!: ObjectId;
31+
32+
/**
33+
* Plan to which promo was applied.
34+
*/
35+
public planId?: ObjectId;
36+
37+
/**
38+
* Benefit type at application time.
39+
*/
40+
public benefitType!: PromoCodeBenefitType;
41+
42+
/**
43+
* Price before promo.
44+
*/
45+
public originalAmount?: number;
46+
47+
/**
48+
* Price after promo.
49+
*/
50+
public finalAmount?: number;
51+
52+
/**
53+
* Actual discount amount.
54+
*/
55+
public discountAmount?: number;
56+
57+
/**
58+
* UTM parameters captured on apply.
59+
*/
60+
public utm?: PromoCodeUsageDBScheme['utm'];
61+
62+
/**
63+
* Application date.
64+
*/
65+
public appliedAt!: Date;
66+
67+
/**
68+
* Model's collection.
69+
*/
70+
protected collection: Collection<PromoCodeUsageDBScheme>;
71+
72+
/**
73+
* Create PromoCodeUsage instance.
74+
*
75+
* @param usageData - usage data
76+
*/
77+
constructor(usageData: PromoCodeUsageDBScheme) {
78+
super(usageData);
79+
this.collection = this.dbConnection.collection<PromoCodeUsageDBScheme>('promoCodeUsages');
80+
}
81+
}

0 commit comments

Comments
 (0)