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
feat(billing): implement promo code functionality and update related types