Skip to content

Commit 10f578e

Browse files
committed
feat(billing): enhance promo validation and payment processing logic in CloudPayments integration
1 parent f5b405f commit 10f578e

10 files changed

Lines changed: 874 additions & 91 deletions

File tree

src/billing/cloudpayments.ts

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,10 @@ export default class CloudPaymentsWebhooks {
164164
let promoPricing;
165165

166166
/**
167-
* Record promo usage before applying paid benefits.
167+
* Revalidate promo before accepting payment.
168168
*
169-
* /pay runs after CloudPayments has accepted the charge, but workspace plan
170-
* must not be changed if promo usage cannot be stored. Otherwise a transient
171-
* DB/limit error would grant a discounted plan without consuming the promo.
169+
* Amount check uses server-side pricing; usage is recorded later in /pay
170+
* after workspace plan is updated successfully.
172171
*/
173172
if (data.promo && !data.isCardLinkOperation) {
174173
try {
@@ -211,6 +210,21 @@ export default class CloudPaymentsWebhooks {
211210
return;
212211
}
213212

213+
if (
214+
data.promo &&
215+
recurrentPaymentSettings?.amount !== undefined &&
216+
+recurrentPaymentSettings.amount !== plan.monthlyCharge
217+
) {
218+
this.sendError(
219+
res,
220+
CheckCodes.WRONG_AMOUNT,
221+
'[Billing / Check] Recurrent amount must equal full plan price when promo is applied',
222+
body
223+
);
224+
225+
return;
226+
}
227+
214228
/**
215229
* Create business operation about creation of subscription
216230
*/
@@ -308,36 +322,6 @@ export default class CloudPaymentsWebhooks {
308322
return;
309323
}
310324

311-
if (data.promo && !data.isCardLinkOperation) {
312-
try {
313-
const promoCodeService = new PromoCodeService(req.context.factories);
314-
const promoPricing = await promoCodeService.getPricingForPromoCodeId(
315-
data.promo.id,
316-
data.userId,
317-
data.workspaceId,
318-
tariffPlan
319-
);
320-
321-
await promoCodeService.createUsage({
322-
promoCode: promoPricing.promoCode,
323-
userId: data.userId,
324-
workspaceId: workspace._id,
325-
planId: tariffPlan._id,
326-
benefitType: promoPricing.benefitType,
327-
originalAmount: promoPricing.originalAmount,
328-
finalAmount: promoPricing.finalAmount,
329-
discountAmount: promoPricing.discountAmount,
330-
utm: data.promo.utm,
331-
});
332-
} catch (e) {
333-
const error = e as Error;
334-
335-
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body);
336-
337-
return;
338-
}
339-
}
340-
341325
try {
342326
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);
343327

@@ -371,6 +355,32 @@ export default class CloudPaymentsWebhooks {
371355
return;
372356
}
373357

358+
if (data.promo && !data.isCardLinkOperation) {
359+
try {
360+
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+
);
367+
368+
await promoCodeService.createUsage({
369+
promoCode: promoPricing.promoCode,
370+
userId: data.userId,
371+
workspaceId: workspace._id,
372+
planId: tariffPlan._id,
373+
benefitType: promoPricing.benefitType,
374+
originalAmount: promoPricing.originalAmount,
375+
finalAmount: promoPricing.finalAmount,
376+
discountAmount: promoPricing.discountAmount,
377+
utm: data.promo.utm,
378+
});
379+
} catch (error) {
380+
console.error('[Billing / Pay] Failed to record promo usage after plan change', error);
381+
}
382+
}
383+
374384
// let accountId = workspace.accountId;
375385

376386
/*

src/resolvers/billingNew.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { TelegramBotURLs } from '../utils/telegram';
1717
import PromoCodeService, { PromoCodeError, PromoCodeErrorCode, PromoCodePreviewResult, buildPaymentPromoData } from '../services/promoCodeService';
1818
import { publish } from '../rabbitmq';
1919
import type { PaymentPromoData } from '../billing/types/paymentData';
20-
import { validateUtmParams } from '../utils/utm/utm';
20+
import { sanitizeUtmParams } from '../utils/utm/utm';
2121

2222
/**
2323
* The amount we will debit to confirm the subscription.
@@ -108,7 +108,7 @@ async function previewOrApplyPromoCode(
108108
input.value,
109109
userId,
110110
workspace,
111-
validateUtmParams(input.utm)
111+
sanitizeUtmParams(input.utm)
112112
);
113113

114114
await notifyLimiterToUnblockWorkspace(workspace._id.toString());
@@ -171,6 +171,7 @@ export default {
171171
): Promise<{
172172
invoiceId: string;
173173
plan: { id: string; name: string; monthlyCharge: number };
174+
chargeAmount: number;
174175
isCardLinkOperation: boolean;
175176
currency: string;
176177
checksum: string;
@@ -185,7 +186,7 @@ export default {
185186
};
186187
}> {
187188
const { workspaceId, tariffPlanId, shouldSaveCard, promoCode } = input;
188-
const promoUtm = validateUtmParams(input.promoUtm);
189+
const promoUtm = sanitizeUtmParams(input.promoUtm);
189190

190191
if (!workspaceId || !tariffPlanId || !user?.id) {
191192
throw new UserInputError('No workspaceId, tariffPlanId or user id provided');
@@ -294,8 +295,9 @@ debug: ${Boolean(workspace.isDebug)}`
294295
plan: {
295296
id: plan._id.toString(),
296297
name: plan.name,
297-
monthlyCharge: paymentAmount,
298+
monthlyCharge: plan.monthlyCharge,
298299
},
300+
chargeAmount: isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount,
299301
isCardLinkOperation,
300302
currency: 'RUB',
301303
checksum,
@@ -472,6 +474,7 @@ debug: ${Boolean(workspace.isDebug)}`
472474
recurrent: {
473475
interval,
474476
period: 1,
477+
amount: plan.monthlyCharge,
475478
},
476479
};
477480

@@ -481,7 +484,6 @@ debug: ${Boolean(workspace.isDebug)}`
481484
*/
482485
if (!isTariffPlanExpired) {
483486
jsonData.cloudPayments.recurrent.startDate = dueDate.toDateString();
484-
jsonData.cloudPayments.recurrent.amount = planPaymentAmount;
485487
}
486488
}
487489

src/services/promoCodeService.ts

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import WorkspaceModel from '../models/workspace';
1010
import { ContextFactories } from '../types/graphql';
1111
import type { Utm } from '@hawk.so/types';
1212
import type { PaymentPromoData } from '../billing/types/paymentData';
13+
import { sanitizeUtmParams } from '../utils/utm/utm';
1314

1415
const PROMO_CODE_REGEXP = /^[A-Z0-9_-]+$/;
1516
const DEFAULT_MIN_FINAL_PRICE = 1;
@@ -347,9 +348,11 @@ function validateBenefitStructure(benefit: PromoCodeBenefit): void {
347348
* @returns promo reference for payment checksum
348349
*/
349350
export function buildPaymentPromoData(promoCodeId: string, utm?: Utm): PaymentPromoData {
351+
const sanitizedUtm = sanitizeUtmParams(utm);
352+
350353
return {
351354
id: promoCodeId,
352-
...(utm && Object.keys(utm).length > 0 ? { utm } : {}),
355+
...(sanitizedUtm ? { utm: sanitizedUtm } : {}),
353356
};
354357
}
355358

@@ -502,53 +505,41 @@ export default class PromoCodeService {
502505
throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Grant plan is unavailable');
503506
}
504507

508+
const now = new Date();
509+
510+
try {
511+
await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId);
512+
await workspace.updateLastChargeDate(now);
513+
await workspace.changePlan(plan._id);
514+
} catch (error) {
515+
if (error instanceof PromoCodeError) {
516+
throw error;
517+
}
518+
519+
throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed');
520+
}
521+
505522
try {
506-
const now = new Date();
507-
508-
/**
509-
* Reserve usage before granting the plan.
510-
*
511-
* This makes promo usage a precondition for the benefit: if limits are exhausted
512-
* or the insert fails, workspace state is not changed.
513-
*/
514-
const usage = await this.createUsage({
523+
await this.createUsage({
515524
promoCode,
516525
userId,
517526
workspaceId: workspace._id,
518527
planId: plan._id,
519528
benefitType: promoCode.benefit.type,
520529
utm,
521530
});
522-
523-
try {
524-
await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId);
525-
await workspace.updateLastChargeDate(now);
526-
await workspace.changePlan(plan._id);
527-
} catch (error) {
528-
try {
529-
await this.factories.promoCodeUsagesFactory.deleteById(usage._id);
530-
} catch (rollbackError) {
531-
console.error('Failed to rollback promo usage after grant_plan apply failure', rollbackError);
532-
}
533-
534-
throw error;
535-
}
536-
537-
return plan;
538531
} catch (error) {
539-
if (error instanceof PromoCodeError) {
540-
throw error;
541-
}
542-
543-
throw new PromoCodeError(PromoCodeErrorCode.ApplyFailed, 'Grant plan apply failed');
532+
console.error('[PromoCode] Failed to record promo usage after grant_plan apply', error);
544533
}
534+
535+
return plan;
545536
}
546537

547538
/**
548539
* Creates usage after successful payment or before immediate grant_plan apply.
549540
*
550-
* Unique indexes on promoCodeId + userId/workspaceId make this method the durable
551-
* reservation point. Callers should grant the promo benefit only after it succeeds.
541+
* Unique indexes on promoCodeId + userId/workspaceId enforce one usage per user/workspace.
542+
* Usage is recorded after plan change in CloudPayments /pay and grant_plan apply.
552543
*
553544
* @param params - usage creation params
554545
* @returns created promo usage
@@ -566,6 +557,8 @@ export default class PromoCodeService {
566557
}): Promise<PromoCodeUsageModel> {
567558
await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId);
568559

560+
const utm = sanitizeUtmParams(params.utm);
561+
569562
try {
570563
return await this.factories.promoCodeUsagesFactory.create({
571564
promoCodeId: params.promoCode._id,
@@ -577,7 +570,7 @@ export default class PromoCodeService {
577570
finalAmount: params.finalAmount,
578571
discountAmount: params.discountAmount,
579572
appliedAt: new Date(),
580-
...(params.utm && Object.keys(params.utm).length > 0 ? { utm: params.utm } : {}),
573+
...(utm ? { utm } : {}),
581574
});
582575
} catch (error) {
583576
if ((error as { code?: number }).code === 11000) {

src/typeDefs/billing.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ type ComposePaymentPlanInfo {
4949
name: String!
5050
5151
"""
52-
Monthly charge for plan
52+
Monthly charge for plan (full tariff price)
5353
"""
5454
monthlyCharge: Int!
5555
}
@@ -386,6 +386,11 @@ type ComposePaymentResponse {
386386
"""
387387
plan: ComposePaymentPlanInfo!
388388
389+
"""
390+
Amount to charge for this payment (may differ from plan.monthlyCharge when promo is applied)
391+
"""
392+
chargeAmount: Int!
393+
389394
"""
390395
True if only card linking validation payment is expected
391396
"""

src/utils/utm/utm.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,18 @@ export function validateUtmParams(utm: unknown): Utm | undefined {
6262

6363
return result;
6464
}
65+
66+
/**
67+
* Returns sanitized UTM params ready for storage, or undefined when nothing valid remains.
68+
*
69+
* @param utm - raw UTM parameters
70+
*/
71+
export function sanitizeUtmParams(utm: unknown): Utm | undefined {
72+
const validated = validateUtmParams(utm);
73+
74+
if (!validated || Object.keys(validated).length === 0) {
75+
return undefined;
76+
}
77+
78+
return validated;
79+
}

0 commit comments

Comments
 (0)