Skip to content

feat(billing): implement promo code functionality#657

Open
Dobrunia wants to merge 19 commits into
masterfrom
feat/promo-code
Open

feat(billing): implement promo code functionality#657
Dobrunia wants to merge 19 commits into
masterfrom
feat/promo-code

Conversation

@Dobrunia

Copy link
Copy Markdown
Member

feat(billing): implement promo code functionality and update related types

@github-actions github-actions Bot marked this pull request as ready for review June 12, 2026 11:48
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for adding a description — the PR is now marked as Ready for Review.

Comment thread src/billing/types/paymentData.ts Outdated
Comment on lines +64 to +88
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets wrap it with "promo" property. Also, maybe promo.id is enough?

Comment thread src/billing/types/paymentData.ts Outdated
Comment on lines +57 to +77
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/billing/cloudpayments.ts Outdated
*/
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explain cases in jsdoc

*/
private async ensureIndexesOnce(): Promise<void> {
if (!this.indexesPromise) {
this.indexesPromise = this.collection.createIndex({ value: 1 }, { unique: true }).then(() => undefined);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if api will be restarted? will it ry to create another index and get an error?

Comment thread src/resolvers/billingNew.ts Outdated

Mutation: {
/**
* Preview discount promo or immediately apply grant_plan promo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explain what is preview

Comment thread src/resolvers/billingNew.ts Outdated

const member = await workspace.getMemberInfo(user.id);

if (!member || !('isAdmin' in member) || !member.isAdmin) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

access rights check could be done via "@requireAdmin" directive on GraphQL scheme

Comment thread src/resolvers/billingNew.ts Outdated
throw new UserInputError('Wrong checksum data');
}

const planPaymentAmount = paymentData.promo?.finalAmount ?? plan.monthlyCharge;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/utils/promoCodeService.ts Outdated
*
* @param plan - tariff plan
*/
function isPlanAvailable(plan: PlanModel): boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming is not clear enough. maybe isPlanAvailableForPurchase?

/**
* Factories used by promo code service.
*/
private readonly factories: ContextFactories;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are use sure it should be done on API side? Maybe it can be calculated on frontend?

Comment thread test/utils/promoCodeService.test.ts Outdated
expect(normalizePromoCodeValue(' promo_2026 ')).toBe('PROMO_2026');
});

it('calculates percent discount with min final price', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add describe section explaining which method are you testing

describe('calculatePromoCodePlanPrice()', () => {
  it('should <do something> when <case you are testing>', () => {
    // ...
  })
})

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Utm type 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.

Comment thread src/services/promoCodeService.ts Outdated
Comment on lines +499 to +503
await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId);
await workspace.updateLastChargeDate(now);
await workspace.changePlan(plan._id);
await this.createUsage({
promoCode,
Comment thread src/billing/cloudpayments.ts Outdated
Comment on lines +330 to +350
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,
});
}
Comment thread src/resolvers/billingNew.ts Outdated
Comment on lines +165 to +171
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);
Comment on lines +313 to +317
async previewPromoCode(
_obj: undefined,
{ input }: PreviewPromoCodeArgs,
{ user, factories }: ResolverContextWithUser
): Promise<PromoCodePreviewResult & { applied: boolean }> {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants