Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9d1f51a
feat(billing): implement promo code functionality and update related …
Dobrunia Jun 11, 2026
d03e093
Bump version up to 1.5.4
github-actions[bot] Jun 12, 2026
fb4e419
utm
Dobrunia Jun 12, 2026
e85c91e
chore: update @hawk.so/types to version 0.6.3
Dobrunia Jun 12, 2026
0aa6738
fix
Dobrunia Jun 12, 2026
72a6e60
feat(promoCode): enhance discount logic and add tests for plan applic…
Dobrunia Jun 12, 2026
377fa6f
feat(billing): refactor promo code handling and update payment data s…
Dobrunia Jun 12, 2026
e926076
lint fix
Dobrunia Jun 13, 2026
4c25dc0
feat(billing): introduce PaymentPromoBenefitType and update promo dat…
Dobrunia Jun 13, 2026
515a9e8
refactor(billing): streamline promo code handling and update payment …
Dobrunia Jun 13, 2026
94001e8
refactor(billing): enhance payment amount validation and improve prom…
Dobrunia Jun 13, 2026
5b2f68a
feat(billing): enhance promo code validation and extend admin checks …
Dobrunia Jun 13, 2026
0b74706
feat(billing): implement previewOrApplyPromoCode function to streamli…
Dobrunia Jun 13, 2026
1197be9
refactor(billing): update promo code handling to calculate payment am…
Dobrunia Jun 13, 2026
210de9f
refactor(billing): move PromoCodeService to services directory and re…
Dobrunia Jun 13, 2026
cad0777
fix(billing): improve error handling in workspace billing updates and…
Dobrunia Jun 13, 2026
2f8e5b5
feat(billing): add tests for promo code application and validation in…
Dobrunia Jun 13, 2026
be4f3b2
feat(billing): implement promo usage reservation and rollback mechani…
Dobrunia Jun 13, 2026
f5b405f
refactor(billing): simplify promo code retrieval and improve index in…
Dobrunia Jun 13, 2026
10f578e
feat(billing): enhance promo validation and payment processing logic …
Dobrunia Jun 15, 2026
7945cdb
refactor(billing): remove unused index initialization logic from prom…
Dobrunia Jun 15, 2026
d782e2b
refactor(billing): rename and restructure promo code application logi…
Dobrunia Jun 15, 2026
e9ed561
refactor(billing): rename applyPromoCode to verifyPromoCode and updat…
Dobrunia Jun 15, 2026
b0ef27a
refactor(billing): streamline promo code pricing calculation logic fo…
Dobrunia Jun 15, 2026
627f8fb
refactor(migrations): update index dropping syntax for promo codes an…
Dobrunia Jun 15, 2026
cf31f71
refactor(billing): enhance CloudPayments test suite with additional m…
Dobrunia Jun 15, 2026
e0af65c
refactor(billing): improve promo code usage creation logic and enhanc…
Dobrunia Jun 16, 2026
5693467
refactor(billing): remove unused promo code fields and simplify payme…
Dobrunia Jun 16, 2026
158f2ef
refactor(billing): enhance promo billing test suite with improved fix…
Dobrunia Jun 16, 2026
02d13a2
fix (tests)
Dobrunia Jun 17, 2026
910ac27
refactor(billing): enhance CloudPayments webhook handling and improve…
Dobrunia Jun 17, 2026
2e5ad23
test(billing): add tests for subscription renewal data retrieval and …
Dobrunia Jun 17, 2026
36af36d
refactor(billing): remove unused promo code fields and update charge …
Dobrunia Jun 17, 2026
9c2e51e
test(billing): add tests for card-link checksum handling and amount v…
Dobrunia Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions migrations/20260615140000-add-promo-code-indexes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @file Migration to add indexes for promoCodes and promoCodeUsages collections
*/
module.exports = {
async up(db) {
const promoCodes = db.collection('promoCodes');
const promoCodeUsages = db.collection('promoCodeUsages');

await promoCodes.createIndex({ value: 1 }, { unique: true });

await promoCodeUsages.createIndex({ promoCodeId: 1 });
await promoCodeUsages.createIndex({ promoCodeId: 1, userId: 1 }, { unique: true });
await promoCodeUsages.createIndex({ promoCodeId: 1, workspaceId: 1 }, { unique: true });
await promoCodeUsages.createIndex({ workspaceId: 1 });
await promoCodeUsages.createIndex({ userId: 1 });
},

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 });
},
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.3",
"version": "1.5.4",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -42,7 +42,7 @@
"@graphql-tools/schema": "^8.5.1",
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.2",
"@hawk.so/types": "^0.5.9",
"@hawk.so/types": "^0.6.3",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
"@octokit/oauth-methods": "^4.0.0",
Expand Down
118 changes: 108 additions & 10 deletions src/billing/cloudpayments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import cloudPaymentsApi from '../utils/cloudPaymentsApi';
import PlanModel from '../models/plan';
import { ClientApi, ClientService, CustomerReceiptItem, ReceiptApi, ReceiptTypes, TaxationSystem } from 'cloudpayments';
import PromoCodeService from '../services/promoCodeService';

const PENNY_MULTIPLIER = 100;

Expand Down Expand Up @@ -88,7 +89,7 @@
return router;
}

/**

Check warning on line 92 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Generates invoice id for payment
*
* @param tariffPlan - tariff plan to generate invoice id
Expand Down Expand Up @@ -141,7 +142,7 @@

let workspace: WorkspaceModel;
let member: ConfirmedMemberDBScheme;
let plan: PlanDBScheme;
let plan: PlanModel;
let planId: string;

const { workspaceId, userId, tariffPlanId } = data;
Expand All @@ -160,19 +161,70 @@
}

const recurrentPaymentSettings = data.cloudPayments?.recurrent;
let promoPricing;

/**
* The amount will be considered correct if it is equal to the cost of the tariff plan.
* Also, the cost will be correct if it is a payment to activate the subscription.
* Revalidate promo before accepting payment.
*
* Amount check uses server-side pricing; usage is recorded later in /pay
* after workspace plan is updated successfully.
*/
const isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate;
if (data.promo && !data.isCardLinkOperation) {
try {
const promoCodeService = new PromoCodeService(context.factories);

promoPricing = await promoCodeService.getPricingForPromoCodeId(
data.promo.id,
data.userId,
data.workspaceId,
plan
);
} catch (e) {
const error = e as Error;

this.sendError(res, CheckCodes.PAYMENT_COULD_NOT_BE_ACCEPTED, `[Billing / Check] Promo code is invalid: ${error.toString()}`, body);

return;
}
}

/**
* Validates payment amount from CloudPayments against expected charge.
*
* expectedAmount:
* - with promo: final price recalculated on the server by promo id
* - without promo: full selected plan monthly charge
*
* isRightAmount is true when:
* 1) body.Amount equals expectedAmount — regular one-time payment (with or without promo)
* 2) no promo and recurrent.startDate is set — subscription is created with a deferred first charge;
* current payment can be a card-link/auth amount (for example 1 RUB) while recurrent.amount
* stores the real plan price for future charges
*/
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;
Comment on lines +210 to 216
}

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

  1. Yes, recurrent payment notifications still go through /check.

  2. For real subscription renewals data.promo is not passed. Renewals are resolved by SubscriptionId/AccountId, without widget Data/checksum. Promo is only present on the first widget payment when the signed checksum contains promo.id.

data.promo &&
recurrentPaymentSettings?.amount !== undefined &&
+recurrentPaymentSettings.amount !== plan.monthlyCharge
) {
this.sendError(
res,
CheckCodes.WRONG_AMOUNT,
'[Billing / Check] Recurrent amount must equal full plan price when promo is applied',
body
);

return;
}

/**
* Create business operation about creation of subscription
*/
Expand Down Expand Up @@ -205,7 +257,7 @@
telegram.sendMessage(`🤗 [Billing / Check] All checks passed successfully «${workspace.name}»`, TelegramBotURLs.Money)
.catch(e => console.error('Error while sending message to Telegram: ' + e));

HawkCatcher.send(new Error('[Billing / Check] All checks passed successfully'), body as any);

Check warning on line 260 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: CheckCodes.SUCCESS,
Expand Down Expand Up @@ -303,6 +355,32 @@
return;
}

if (data.promo && !data.isCardLinkOperation) {
try {
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: 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.

} catch (error) {
console.error('[Billing / Pay] Failed to record promo usage after plan change', error);
}
}

// let accountId = workspace.accountId;

/*
Expand Down Expand Up @@ -442,7 +520,7 @@
*/
const userEmail = body.IssuerBankCountry === RUSSIA_ISO_CODE ? user.email : undefined;

await this.sendReceipt(workspace, tariffPlan, userEmail);
await this.sendReceipt(workspace, tariffPlan, userEmail, +body.Amount);

let messageText = '';

Expand Down Expand Up @@ -555,7 +633,7 @@

this.handleSendingToTelegramError(telegram.sendMessage(`❌ [Billing / Fail] Transaction failed for «${workspace.name}»`, TelegramBotURLs.Money));

HawkCatcher.send(new Error('[Billing / Fail] Transaction failed'), body as any);

Check warning on line 636 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type

res.json({
code: FailCodes.SUCCESS,
Expand Down Expand Up @@ -737,7 +815,7 @@
* @param errorText - error description
* @param backtrace - request data and error data
*/
private sendError(res: express.Response, errorCode: CheckCodes | PayCodes | FailCodes | RecurrentCodes, errorText: string, backtrace: { [key: string]: any }): void {

Check warning on line 818 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type
res.json({
code: errorCode,
});
Expand Down Expand Up @@ -765,10 +843,29 @@
*/
if (body.Data) {
const parsedData = JSON.parse(body.Data || '{}') as WebhookData;
const checksumData = checksumService.parseAndVerifyChecksum(parsedData.checksum);

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

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was added to avoid trusting unsigned CloudPayments Data for billing intent fields.

Previously we merged verified checksum data with parsed widget Data, so client-controlled Data could override fields like workspaceId, tariffPlanId, userId, shouldSaveCard or promo after checksum verification.

Now checksumData is the source of truth. The only field we still take from Data is cloudPayments.recurrent, because it is needed for subscription settings and is validated later in /check against server-side pricing.

The isCardLinkOperation branch keeps compatibility with the card-link checksum shape: card-link operations do not have tariffPlanId/shouldSaveCard, so we normalize them to the common PaymentData shape.


return {
...checksumService.parseAndVerifyChecksum(parsedData.checksum),
...parsedData,
...checksumData,
...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}),
isCardLinkOperation: false,
};
}

Expand Down Expand Up @@ -802,7 +899,7 @@
promise.catch(e => console.error('Error while sending message to Telegram: ' + e));
}

/**

Check warning on line 902 in src/billing/cloudpayments.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Parses body and returns card data
* @param request - request body to parse
*/
Expand All @@ -826,8 +923,9 @@
* @param workspace - workspace for which payment is made
* @param tariff - paid tariff plan
* @param userMail - user email address
* @param amount - actual paid amount
*/
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string): Promise<void> {
private async sendReceipt(workspace: WorkspaceModel, tariff: PlanModel, userMail?: string, amount = tariff.monthlyCharge): Promise<void> {
/**
* A general tax that applies to all commercial activities
* involving the production and distribution of goods and the provision of services
Expand All @@ -836,9 +934,9 @@
const VALUE_ADDED_TAX = 0;

const item: CustomerReceiptItem = {
amount: tariff.monthlyCharge,
amount,
label: `${tariff.name} tariff plan`,
price: tariff.monthlyCharge,
price: amount,
vat: VALUE_ADDED_TAX,
quantity: 1,
};
Expand Down
22 changes: 22 additions & 0 deletions src/billing/types/paymentData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Utm } from '@hawk.so/types';

/**
* Data for setting up recurring payments
*/
Expand Down Expand Up @@ -35,6 +37,22 @@ interface CloudPaymentsSettings {
recurrent: RecurrentPaymentSettings;
}

/**
* Promo reference attached to payment request.
* Amounts are resolved on the server by promo id during check/pay.
*/
export interface PaymentPromoData {
/**
* Applied promo code id
*/
id: string;

/**
* UTM parameters captured when promo was applied
*/
utm?: Utm;
}

export interface PaymentData {
/**
* Data for Cloudpayments needs
Expand All @@ -56,6 +74,10 @@ export interface PaymentData {
* If true, we will save user card
*/
shouldSaveCard: boolean;
/**
* Applied promo code reference
*/
promo?: PaymentPromoData;
/**
* True if this is card linking operation – charging minimal amount of money to validate card info
*/
Expand Down
8 changes: 8 additions & 0 deletions src/directives/requireAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export default function requireAdminDirective(directiveName = 'requireAdmin') {
await checkByWorkspaceId(context, args.workspaceId);
}

if (args.input?.workspaceId) {
await checkByWorkspaceId(context, args.input.workspaceId);
}

if (args.projectId) {
await checkByProjectId(context, args.projectId);
}

if (args.input?.projectId) {
await checkByProjectId(context, args.input.projectId);
}
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import ReleasesFactory from './models/releasesFactory';
import RedisHelper from './redisHelper';
import { appendSsoRoutes } from './sso';
import { appendGitHubRoutes } from './integrations/github';
import PromoCodesFactory from './models/promoCodesFactory';
import PromoCodeUsagesFactory from './models/promoCodeUsagesFactory';

/**
* Option to enable playground
Expand Down Expand Up @@ -172,13 +174,21 @@ class HawkAPI {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const releasesFactory = new ReleasesFactory(mongo.databases.events!);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const promoCodesFactory = new PromoCodesFactory(mongo.databases.hawk!);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const promoCodeUsagesFactory = new PromoCodeUsagesFactory(mongo.databases.hawk!);

return {
usersFactory,
workspacesFactory,
projectsFactory,
plansFactory,
businessOperationsFactory,
releasesFactory,
promoCodesFactory,
promoCodeUsagesFactory,
};
}

Expand Down
66 changes: 66 additions & 0 deletions src/models/promoCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Collection, ObjectId } from 'mongodb';
import AbstractModel from './abstractModel';
import {
PromoCodeBenefit,
PromoCodeDBScheme
} from '@hawk.so/types';

/**
* Model representing promo code settings.
*/
export default class PromoCodeModel extends AbstractModel<PromoCodeDBScheme> implements PromoCodeDBScheme {
/**
* Promo code id.
*/
public _id!: ObjectId;

/**
* Normalized promo code value.
*/
public value!: string;

/**
* Benefit granted by this promo code.
*/
public benefit!: PromoCodeBenefit;

/**
* Maximum successful usages count.
*/
public limit?: number;

/**
* Expiration date.
*/
public expiresAt?: Date;

/**
* Creation date.
*/
public createdAt!: Date;

/**
* Last update date.
*/
public updatedAt!: Date;

/**
* Creator id.
*/
public createdBy!: string;

/**
* Model's collection.
*/
protected collection: Collection<PromoCodeDBScheme>;

/**
* Create PromoCode instance.
*
* @param promoCodeData - promo code data
*/
constructor(promoCodeData: PromoCodeDBScheme) {
super(promoCodeData);
this.collection = this.dbConnection.collection<PromoCodeDBScheme>('promoCodes');
}
}
Loading
Loading