Skip to content

Commit e0af65c

Browse files
committed
refactor(billing): improve promo code usage creation logic and enhance subscription renewal handling in CloudPayments
1 parent cf31f71 commit e0af65c

5 files changed

Lines changed: 113 additions & 47 deletions

File tree

src/billing/cloudpayments.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,17 @@ export default class CloudPaymentsWebhooks {
210210
return;
211211
}
212212

213+
/**
214+
* Validates recurrent.amount for the first subscription payment when promo is applied.
215+
*
216+
* CloudPayments flows:
217+
* 1. First charge via widget — body.Data is present, checksum may include promo.id and
218+
* cloudPayments.recurrent.amount. Discount applies only to body.Amount (first charge).
219+
* recurrent.amount must stay equal to full plan.monthlyCharge so later renewals bill full price.
220+
* 2. Monthly renewals — body.Data is absent, getDataFromRequest() resolves workspace by
221+
* SubscriptionId only, data.promo is undefined, this block is skipped, and amount must
222+
* equal plan.monthlyCharge (see isRightAmount above).
223+
*/
213224
if (
214225
data.promo &&
215226
recurrentPaymentSettings?.amount !== undefined &&
@@ -358,22 +369,12 @@ export default class CloudPaymentsWebhooks {
358369
if (data.promo && !data.isCardLinkOperation) {
359370
try {
360371
const promoCodeService = new PromoCodeService(req.context.factories);
361-
const promoPricing = await promoCodeService.getPricingForPromoCodeId(
362-
data.promo.id,
363-
data.userId,
364-
data.workspaceId,
365-
tariffPlan
366-
);
367372

368373
await promoCodeService.createUsage({
369-
promoCode: promoPricing.promoCode,
374+
promoCodeId: data.promo.id,
370375
userId: data.userId,
371376
workspaceId: workspace._id,
372-
planId: tariffPlan._id,
373-
benefitType: promoPricing.benefitType,
374-
originalAmount: promoPricing.originalAmount,
375-
finalAmount: promoPricing.finalAmount,
376-
discountAmount: promoPricing.discountAmount,
377+
plan: tariffPlan,
377378
utm: data.promo.utm,
378379
});
379380
} catch (error) {
@@ -838,8 +839,8 @@ status: ${body.Status}`
838839
const body: CheckRequest | PayRequest | FailRequest = req.body;
839840

840841
/**
841-
* If Data is not presented in body means there is a recurring payment
842-
* Data field is presented only in one-time payment requests or subscription initial request
842+
* If Data is absent, this is a subscription renewal (or check/pay identified by SubscriptionId).
843+
* Renewals do not carry promo: discount was applied only on the first widget payment.
843844
*/
844845
if (body.Data) {
845846
const parsedData = JSON.parse(body.Data || '{}') as WebhookData;

src/models/promoCodeUsagesFactory.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import PromoCodeUsageModel from './promoCodeUsage';
33
import { Collection, Db, ObjectId } from 'mongodb';
44
import { PromoCodeUsageDBScheme } from '@hawk.so/types';
55

6+
/**
7+
* Input for creating promo usage with MongoDB driver ObjectId instances.
8+
*/
9+
export type PromoCodeUsageCreateInput = Omit<
10+
PromoCodeUsageDBScheme,
11+
'_id' | 'promoCodeId' | 'workspaceId' | 'planId'
12+
> & {
13+
promoCodeId: ObjectId;
14+
workspaceId: ObjectId;
15+
planId?: ObjectId;
16+
};
17+
618
/**
719
* Promo code usages factory to work with promoCodeUsages collection.
820
*/
@@ -74,14 +86,14 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory<PromoCo
7486
*
7587
* @param usageData - promo code usage data
7688
*/
77-
public async create(usageData: Omit<PromoCodeUsageDBScheme, '_id'>): Promise<PromoCodeUsageModel> {
89+
public async create(usageData: PromoCodeUsageCreateInput): Promise<PromoCodeUsageModel> {
7890
const usage = {
7991
_id: new ObjectId(),
8092
...usageData,
8193
};
8294

83-
await this.collection.insertOne(usage);
95+
await this.collection.insertOne(usage as PromoCodeUsageDBScheme);
8496

85-
return new PromoCodeUsageModel(usage);
97+
return new PromoCodeUsageModel(usage as PromoCodeUsageDBScheme);
8698
}
8799
}

src/services/promoCodeService.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -322,37 +322,38 @@ export default class PromoCodeService {
322322
/**
323323
* Creates usage after successful payment.
324324
*
325+
* Re-validates promo by id, resolves pricing for the selected plan, and stores usage.
325326
* Unique indexes on promoCodeId + userId/workspaceId enforce one usage per user/workspace.
326-
* Usage is recorded after plan change in CloudPayments /pay.
327327
*
328328
* @param params - usage creation params
329329
* @returns created promo usage
330330
*/
331331
public async createUsage(params: {
332-
promoCode: PromoCodeModel;
332+
promoCodeId: string;
333333
userId: string;
334334
workspaceId: ObjectId;
335-
planId?: ObjectId;
336-
benefitType: PromoCodeBenefitType;
337-
originalAmount?: number;
338-
finalAmount?: number;
339-
discountAmount?: number;
335+
plan: PlanModel;
340336
utm?: PromoCodeUtm;
341337
}): Promise<PromoCodeUsageModel> {
342-
await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId);
338+
const promoPricing = await this.getPricingForPromoCodeId(
339+
params.promoCodeId,
340+
params.userId,
341+
params.workspaceId.toString(),
342+
params.plan
343+
);
343344

344345
const utm = sanitizeUtmParams(params.utm);
345346

346347
try {
347348
return await this.factories.promoCodeUsagesFactory.create({
348-
promoCodeId: params.promoCode._id,
349+
promoCodeId: promoPricing.promoCode._id,
349350
userId: params.userId,
350351
workspaceId: params.workspaceId,
351-
planId: params.planId,
352-
benefitType: params.benefitType,
353-
originalAmount: params.originalAmount,
354-
finalAmount: params.finalAmount,
355-
discountAmount: params.discountAmount,
352+
planId: params.plan._id,
353+
benefitType: promoPricing.benefitType,
354+
originalAmount: promoPricing.originalAmount,
355+
finalAmount: promoPricing.finalAmount,
356+
discountAmount: promoPricing.discountAmount,
356357
appliedAt: new Date(),
357358
...(utm ? { utm } : {}),
358359
});

test/billing/cloudpayments.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,36 @@ describe('CloudPaymentsWebhooks', () => {
592592
expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS });
593593
});
594594

595+
it('should accept full plan amount on subscription renewal check without promo in Data', async () => {
596+
const webhooks = new CloudPaymentsWebhooks() as any;
597+
const workspaceId = new ObjectId().toString();
598+
const userId = new ObjectId().toString();
599+
const plan = createPlan(1000);
600+
const { context, workspace } = createWebhookContext({
601+
workspaceId,
602+
userId,
603+
plan,
604+
subscriptionId: 'subscription-id',
605+
});
606+
607+
context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace);
608+
609+
const res = createMockResponse();
610+
611+
await webhooks.check({
612+
context,
613+
body: {
614+
...createCheckBody(1010, '1000', ''),
615+
SubscriptionId: 'subscription-id',
616+
AccountId: userId,
617+
Data: undefined,
618+
},
619+
}, res);
620+
621+
expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id');
622+
expect(res.json).toHaveBeenCalledWith({ code: CheckCodes.SUCCESS });
623+
});
624+
595625
it('should reject wrong amount when promo is not applied', async () => {
596626
const webhooks = new CloudPaymentsWebhooks() as any;
597627
const workspaceId = new ObjectId().toString();
@@ -689,11 +719,9 @@ describe('CloudPaymentsWebhooks', () => {
689719

690720
expect(changePlan).toHaveBeenCalledWith(plan._id);
691721
expect(createUsage).toHaveBeenCalledWith(expect.objectContaining({
722+
promoCodeId: promoCode._id.toString(),
692723
userId,
693-
benefitType: 'percent_discount',
694-
originalAmount: 1000,
695-
finalAmount: 750,
696-
discountAmount: 250,
724+
plan: expect.objectContaining({ _id: plan._id }),
697725
}));
698726
expect(publish).toHaveBeenCalled();
699727
expect(sendNotification).toHaveBeenCalledWith(

test/services/promoCodeService.test.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -269,36 +269,58 @@ describe('PromoCodeService', () => {
269269
});
270270

271271
describe('createUsage()', () => {
272+
it('should resolve pricing by promo id and create usage record', async () => {
273+
const plan = createPlan({ monthlyCharge: 1000 });
274+
const promoCode = createPromoCode({
275+
type: 'percent_discount',
276+
percent: 25,
277+
});
278+
const service = createService(promoCode, { plan });
279+
280+
const usage = await service.createUsage({
281+
promoCodeId: promoCode._id.toString(),
282+
userId: new ObjectId().toString(),
283+
workspaceId: new ObjectId(),
284+
plan,
285+
});
286+
287+
expect(usage).toMatchObject({ _id: expect.any(ObjectId) });
288+
});
289+
272290
it('should map duplicate usage creation to limit exceeded error', async () => {
291+
const plan = createPlan({ monthlyCharge: 1000 });
273292
const promoCode = createPromoCode({
274293
type: 'fixed_price',
275294
amount: 100,
276295
});
277296
const service = new PromoCodeService({
297+
promoCodesFactory: {
298+
findOne: jest.fn().mockResolvedValue(promoCode),
299+
},
278300
promoCodeUsagesFactory: {
279301
countByPromoCodeId: jest.fn().mockResolvedValue(0),
280302
findByPromoCodeAndUser: jest.fn().mockResolvedValue(null),
281303
findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null),
282304
create: jest.fn().mockRejectedValue({ code: 11000 }),
283305
},
306+
plansFactory: {
307+
findById: jest.fn().mockResolvedValue(plan),
308+
},
284309
} as any);
285310

286311
await expectPromoError(
287312
service.createUsage({
288-
promoCode,
313+
promoCodeId: promoCode._id.toString(),
289314
userId: new ObjectId().toString(),
290315
workspaceId: new ObjectId(),
291-
planId: new ObjectId(),
292-
benefitType: 'fixed_price',
293-
originalAmount: 1000,
294-
finalAmount: 100,
295-
discountAmount: 900,
316+
plan,
296317
}),
297318
PromoCodeErrorCode.LimitExceeded
298319
);
299320
});
300321

301322
it('should reject second createUsage when insert returns duplicate key', async () => {
323+
const plan = createPlan({ monthlyCharge: 1000 });
302324
const promoCode = createPromoCode({
303325
type: 'fixed_price',
304326
amount: 100,
@@ -307,22 +329,24 @@ describe('PromoCodeService', () => {
307329
.mockResolvedValueOnce({ _id: new ObjectId() })
308330
.mockRejectedValueOnce({ code: 11000 });
309331
const service = new PromoCodeService({
332+
promoCodesFactory: {
333+
findOne: jest.fn().mockResolvedValue(promoCode),
334+
},
310335
promoCodeUsagesFactory: {
311336
countByPromoCodeId: jest.fn().mockResolvedValue(0),
312337
findByPromoCodeAndUser: jest.fn().mockResolvedValue(null),
313338
findByPromoCodeAndWorkspace: jest.fn().mockResolvedValue(null),
314339
create,
315340
},
341+
plansFactory: {
342+
findById: jest.fn().mockResolvedValue(plan),
343+
},
316344
} as any);
317345
const usageParams = {
318-
promoCode,
346+
promoCodeId: promoCode._id.toString(),
319347
userId: new ObjectId().toString(),
320348
workspaceId: new ObjectId(),
321-
planId: new ObjectId(),
322-
benefitType: 'fixed_price' as const,
323-
originalAmount: 1000,
324-
finalAmount: 100,
325-
discountAmount: 900,
349+
plan,
326350
};
327351

328352
await service.createUsage(usageParams);

0 commit comments

Comments
 (0)