Skip to content

feat(billing): implement promo code functionality#657

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

feat(billing): implement promo code functionality#657
Dobrunia wants to merge 26 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

Comment thread src/models/promoCodesFactory.ts Outdated
*/
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/?

Comment thread src/services/promoCodeService.ts Outdated
* @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 thread src/resolvers/billingNew.ts Outdated
Comment on lines +313 to +317
async previewPromoCode(
_obj: undefined,
{ input }: PreviewPromoCodeArgs,
{ user, factories }: ResolverContextWithUser
): Promise<PromoCodePreviewResult & { applied: 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.

should be covered by tests

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

Copilot reviewed 27 out of 28 changed files in this pull request and generated 4 comments.

Comment on lines 281 to 286
telegram
.sendMessage(`👀 [Billing / Compose payment]

card link operation: ${isCardLinkOperation}
amount: ${+plan.monthlyCharge} RUB
amount: ${+paymentAmount} RUB
last charge date: ${workspace.lastChargeDate?.toISOString()}
Comment thread src/services/promoCodeService.ts Outdated
Comment on lines +226 to +229
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);
Comment thread src/services/promoCodeService.ts Outdated
Comment on lines +250 to +252
case 'amount_discount': {
const minFinalPrice = benefit.minFinalPrice ?? DEFAULT_MIN_FINAL_PRICE;
const finalAmount = Math.max(originalAmount - benefit.amount, minFinalPrice);
Comment on lines +204 to 210
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;

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

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);

Comment on lines +18 to +27
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 });
Comment on lines +158 to +164
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;
Comment on lines 225 to 227
card link operation: ${isCardLinkOperation}
amount: ${+plan.monthlyCharge} RUB
amount: ${+paymentAmount} RUB
last charge date: ${workspace.lastChargeDate?.toISOString()}
Dobrunia added 2 commits June 15, 2026 19:50
…ocks and utility functions for improved clarity and functionality
return;
}

if (

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.

  1. Does recurrent payment go through the "check" method?
  2. Will data.promo be passed in case of recurrent payment?
  3. Write clear jsdoc for this case
  4. it should be covered by tests and tested manually

Comment on lines +361 to +378
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,
});

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 think createUsage() should accept promoId, and resolve promoPricing internally.

Comment on lines +848 to +863
/**
* 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 } : {}),
};
}

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 can't understand this change. Why it was added?

name: plan.name,
monthlyCharge: plan.monthlyCharge,
},
chargeAmount: isCardLinkOperation ? AMOUNT_FOR_CARD_VALIDATION : paymentAmount,

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 this change

Comment on lines +181 to +184
benefitType: pricing.benefitType,
originalAmount: pricing.originalAmount,
finalAmount: pricing.finalAmount,
discountAmount: pricing.discountAmount,

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.

why it is needed in composePayment?

Comment on lines +378 to +394
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);
}
}

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 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 () => {

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 happening here or separate to methods.

});

expect(apiResponse.data.code).toBe(CheckCodes.SUCCESS);
expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending);

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.

why business operation is pending?

expect(createdBusinessOperation?.status).toBe(BusinessOperationStatus.Pending);
});

it('should reject full plan amount when promo expects discounted charge', async () => {

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 does it mean "reject full plan amount" ?

}

describe('/billing/check', () => {
it('should accept discounted amount when promo is valid', async () => {

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.

wrap promo-related tests to another describe section

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