feat(billing): implement promo code functionality#657
Conversation
|
Thanks for adding a description — the PR is now marked as Ready for Review. |
| promoCodeId?: string; | ||
| /** | ||
| * Applied promo code value | ||
| */ | ||
| promoCodeValue?: string; | ||
| /** | ||
| * Promo benefit type | ||
| */ | ||
| benefitType?: 'grant_plan' | 'percent_discount' | 'amount_discount' | 'fixed_price'; | ||
| /** | ||
| * Plan price before promo | ||
| */ | ||
| originalAmount?: number; | ||
| /** | ||
| * Final price after promo | ||
| */ | ||
| finalAmount?: number; | ||
| /** | ||
| * Actual discount amount | ||
| */ | ||
| discountAmount?: number; | ||
| /** | ||
| * UTM parameters captured when promo was applied | ||
| */ | ||
| promoUtm?: Utm; |
There was a problem hiding this comment.
lets wrap it with "promo" property. Also, maybe promo.id is enough?
| 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; |
There was a problem hiding this comment.
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
| */ | ||
| 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); |
| */ | ||
| private async ensureIndexesOnce(): Promise<void> { | ||
| if (!this.indexesPromise) { | ||
| this.indexesPromise = this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined); |
There was a problem hiding this comment.
what if api will be restarted? will it ry to create another index and get an error?
|
|
||
| Mutation: { | ||
| /** | ||
| * Preview discount promo or immediately apply grant_plan promo. |
|
|
||
| const member = await workspace.getMemberInfo(user.id); | ||
|
|
||
| if (!member || !('isAdmin' in member) || !member.isAdmin) { |
There was a problem hiding this comment.
access rights check could be done via "@requireAdmin" directive on GraphQL scheme
| throw new UserInputError('Wrong checksum data'); | ||
| } | ||
|
|
||
| const planPaymentAmount = paymentData.promo?.finalAmount ?? plan.monthlyCharge; |
There was a problem hiding this comment.
its better to compute final amount on the fly based on plan.monthlyCharge and promo code value.
User can change any value passed from frontend. So we can rely only on promo code id.
| * | ||
| * @param plan - tariff plan | ||
| */ | ||
| function isPlanAvailable(plan: PlanModel): boolean { |
There was a problem hiding this comment.
naming is not clear enough. maybe isPlanAvailableForPurchase?
| /** | ||
| * Factories used by promo code service. | ||
| */ | ||
| private readonly factories: ContextFactories; |
There was a problem hiding this comment.
I'm not sure PromoCodeService should be places to utils folder since it depends on context and makes db requests.
Maybe /services/?
| * @param userId - user id | ||
| * @param workspaceId - workspace id | ||
| */ | ||
| public async preview(value: string, userId: string, workspaceId: string): Promise<PromoCodePreviewResult> { |
There was a problem hiding this comment.
are use sure it should be done on API side? Maybe it can be calculated on frontend?
| expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026'); | ||
| }); | ||
|
|
||
| it('calculates percent discount with min final price', () => { |
There was a problem hiding this comment.
add describe section explaining which method are you testing
describe('calculatePromoCodePlanPrice()', () => {
it('should <do something> when <case you are testing>', () => {
// ...
})
})There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end promo code support to the billing flow, including GraphQL APIs for preview/apply, payment checksum propagation, CloudPayments webhook validation, and persistence for promo code definitions/usages (backed by updated @hawk.so/types).
Changes:
- Introduces
PromoCodeService(pricing/validation/apply + usage recording) and new promo code MongoDB models/factories. - Extends billing GraphQL schema/resolvers to preview/apply promos and attach promo data to payment checksums + webhook processing.
- Updates UTM handling/types to use the shared
Utmtype and threads promo UTM through billing.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Updates lockfile for @hawk.so/types bump. |
| package.json | Bumps app version and updates @hawk.so/types to ^0.6.3. |
| src/utils/promoCodeService.ts | Implements promo code validation/pricing/apply + usage recording. |
| src/utils/utm/utm.ts | Tightens typing and returns Utm from UTM validation. |
| src/utils/checksumService.ts | Adds optional promo payload to billing checksums. |
| src/types/graphql.ts | Extends ContextFactories with promo factories. |
| src/typeDefs/billing.ts | Adds promo inputs/types and previewPromoCode mutation + composePayment promo response. |
| src/resolvers/user.ts | Switches signup UTM typing to shared Utm. |
| src/resolvers/billingNew.ts | Adds promo support in composePayment, payWithCard amount, and new previewPromoCode mutation. |
| src/models/usersFactory.ts | Switches UTM typing in user creation to shared Utm. |
| src/models/user.ts | Switches stored user UTM typing to shared Utm. |
| src/models/promoCodeUsagesFactory.ts | Adds promo usage collection access + limit-enforcing indexes. |
| src/models/promoCodeUsage.ts | Adds promo usage model. |
| src/models/promoCodesFactory.ts | Adds promo codes collection access + unique value index. |
| src/models/promoCode.ts | Adds promo code settings model. |
| src/index.ts | Wires promo factories into request context. |
| src/billing/types/paymentData.ts | Adds PaymentPromoData to payment payload typing. |
| src/billing/cloudpayments.ts | Validates promo data/amounts in /check, records usage in /pay, and updates receipt amount. |
| test/utils/promoCodeService.test.ts | Adds unit tests for promo service behavior. |
| test/sso/saml/controller.test.ts | Updates mocked factories to include promo factories. |
| test/resolvers/project.test.ts | Updates mocked factories to include promo factories. |
| test/resolvers/billingNew.test.ts | Updates mocked factories to include promo factories. |
| test/integrations/github-routes.test.ts | Updates mocked factories to include promo factories. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId); | ||
| await workspace.updateLastChargeDate(now); | ||
| await workspace.changePlan(plan._id); | ||
| await this.createUsage({ | ||
| promoCode, |
| 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, | ||
| }); | ||
| } |
| if (promoCode && !isCardLinkOperation) { | ||
| try { | ||
| const promoCodeService = new PromoCodeService(factories); | ||
| const pricing = await promoCodeService.getPricingForPlan(promoCode, user.id, workspace._id.toString(), plan); | ||
|
|
||
| paymentAmount = pricing.finalAmount; | ||
| paymentPromo = buildPaymentPromoData(pricing, promoUtm); |
| async previewPromoCode( | ||
| _obj: undefined, | ||
| { input }: PreviewPromoCodeArgs, | ||
| { user, factories }: ResolverContextWithUser | ||
| ): Promise<PromoCodePreviewResult & { applied: boolean }> { |
…o code index handling
…ne promo code application and workspace unblocking
…ount based on promo validity
…move unused utils
… promo code processing
… billingNew resolver
…sm in billing logic
…itialization logic
…in CloudPayments integration
…o codes and usages factories
| telegram | ||
| .sendMessage(`👀 [Billing / Compose payment] | ||
|
|
||
| card link operation: ${isCardLinkOperation} | ||
| amount: ${+plan.monthlyCharge} RUB | ||
| amount: ${+paymentAmount} RUB | ||
| last charge date: ${workspace.lastChargeDate?.toISOString()} |
| case 'percent_discount': { | ||
| const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; | ||
| const discountAmount = Math.floor(originalAmount * benefit.percent / 100); | ||
| const finalAmount = Math.max(originalAmount - discountAmount, minFinalPrice); |
| case 'amount_discount': { | ||
| const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE; | ||
| const finalAmount = Math.max(originalAmount - benefit.amount, minFinalPrice); |
| const expectedAmount = promoPricing?.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; |
…c to improve clarity and remove deprecated methods
…e related types and logic for clarity
…r improved readability and maintainability
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/billing/cloudpayments.ts:209
- The amount validation now compares against
expectedAmount(discounted when a promo is applied), but the error message still says it must equal the plan monthly charge. This is misleading when promos are used and makes troubleshooting harder.
const expectedAmount = promoPricing?.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);
| async down(db) { | ||
| const promoCodes = db.collection('promoCodes'); | ||
| const promoCodeUsages = db.collection('promoCodeUsages'); | ||
|
|
||
| await promoCodes.dropIndex({ value: 1 }); | ||
| await promoCodeUsages.dropIndex({ promoCodeId: 1 }); | ||
| await promoCodeUsages.dropIndex({ promoCodeId: 1, userId: 1 }); | ||
| await promoCodeUsages.dropIndex({ promoCodeId: 1, workspaceId: 1 }); | ||
| await promoCodeUsages.dropIndex({ workspaceId: 1 }); | ||
| await promoCodeUsages.dropIndex({ userId: 1 }); |
| switch (benefit.type) { | ||
| case 'percent_discount': | ||
| if (typeof benefit.percent !== 'number' || benefit.percent <= 0 || benefit.percent > 100) { | ||
| throw new PromoCodeError(PromoCodeErrorCode.Invalid, 'Percent discount is invalid'); | ||
| } | ||
|
|
||
| return; |
| card link operation: ${isCardLinkOperation} | ||
| amount: ${+plan.monthlyCharge} RUB | ||
| amount: ${+paymentAmount} RUB | ||
| last charge date: ${workspace.lastChargeDate?.toISOString()} |
…d usages for consistency
…ocks and utility functions for improved clarity and functionality
| return; | ||
| } | ||
|
|
||
| if ( |
There was a problem hiding this comment.
- Does recurrent payment go through the "check" method?
- Will data.promo be passed in case of recurrent payment?
- Write clear jsdoc for this case
- it should be covered by tests and tested manually
| 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: promoPricing.benefitType, | ||
| originalAmount: promoPricing.originalAmount, | ||
| finalAmount: promoPricing.finalAmount, | ||
| discountAmount: promoPricing.discountAmount, | ||
| utm: data.promo.utm, | ||
| }); |
There was a problem hiding this comment.
I think createUsage() should accept promoId, and resolve promoPricing internally.
| /** | ||
| * Treat checksum as the source of truth for billing intent. | ||
| * | ||
| * Widget Data is client-controlled, so it must not override signed fields like | ||
| * workspaceId, tariffPlanId, userId, shouldSaveCard, or promo id. Only | ||
| * CloudPayments recurrent settings are accepted from Data because they are | ||
| * validated separately against server-side pricing in /check. | ||
| */ | ||
| if ('isCardLinkOperation' in checksumData) { | ||
| return { | ||
| ...checksumData, | ||
| tariffPlanId: '', | ||
| shouldSaveCard: false, | ||
| ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), | ||
| }; | ||
| } |
There was a problem hiding this comment.
I can't understand this change. Why it was added?
| name: plan.name, | ||
| monthlyCharge: plan.monthlyCharge, | ||
| }, | ||
| chargeAmount: isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount, |
| benefitType: pricing.benefitType, | ||
| originalAmount: pricing.originalAmount, | ||
| finalAmount: pricing.finalAmount, | ||
| discountAmount: pricing.discountAmount, |
There was a problem hiding this comment.
why it is needed in composePayment?
| let planPaymentAmount = plan.monthlyCharge; | ||
|
|
||
| if (paymentData.promo?.id) { | ||
| try { | ||
| const pricing = await new PromoCodeService(factories).getPricingForPromoCodeId( | ||
| paymentData.promo.id, | ||
| user.id, | ||
| paymentData.workspaceId, | ||
| plan | ||
| ); | ||
|
|
||
| planPaymentAmount = pricing.finalAmount; | ||
| } catch (error) { | ||
| throwPromoCodeGraphQLError(error); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
I'm not sure payWithCard is still used. And if it is used, probably, it won't accept promo-code. So this code is probably redundant
| promoCodeUsagesCollection = accountsDb.collection<Omit<PromoCodeUsageDBScheme, '_id'>>('promoCodeUsages'); | ||
| }); | ||
|
|
||
| beforeEach(async () => { |
There was a problem hiding this comment.
explain what is happening here or separate to methods.
| }); | ||
|
|
||
| expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS); | ||
| expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); |
There was a problem hiding this comment.
why business operation is pending?
| expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending); | ||
| }); | ||
|
|
||
| it('should reject full plan amount when promo expects discounted charge', async () => { |
There was a problem hiding this comment.
what does it mean "reject full plan amount" ?
| } | ||
|
|
||
| describe('/billing/check', () => { | ||
| it('should accept discounted amount when promo is valid', async () => { |
There was a problem hiding this comment.
wrap promo-related tests to another describe section
feat(billing): implement promo code functionality and update related types