-
Notifications
You must be signed in to change notification settings - Fork 2
feat(billing): implement promo code functionality #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Dobrunia
wants to merge
35
commits into
master
Choose a base branch
from
feat/promo-code
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
9d1f51a
feat(billing): implement promo code functionality and update related …
Dobrunia d03e093
Bump version up to 1.5.4
github-actions[bot] fb4e419
utm
Dobrunia e85c91e
chore: update @hawk.so/types to version 0.6.3
Dobrunia 0aa6738
fix
Dobrunia 72a6e60
feat(promoCode): enhance discount logic and add tests for plan applic…
Dobrunia 377fa6f
feat(billing): refactor promo code handling and update payment data s…
Dobrunia e926076
lint fix
Dobrunia 4c25dc0
feat(billing): introduce PaymentPromoBenefitType and update promo dat…
Dobrunia 515a9e8
refactor(billing): streamline promo code handling and update payment …
Dobrunia 94001e8
refactor(billing): enhance payment amount validation and improve prom…
Dobrunia 5b2f68a
feat(billing): enhance promo code validation and extend admin checks …
Dobrunia 0b74706
feat(billing): implement previewOrApplyPromoCode function to streamli…
Dobrunia 1197be9
refactor(billing): update promo code handling to calculate payment am…
Dobrunia 210de9f
refactor(billing): move PromoCodeService to services directory and re…
Dobrunia cad0777
fix(billing): improve error handling in workspace billing updates and…
Dobrunia 2f8e5b5
feat(billing): add tests for promo code application and validation in…
Dobrunia be4f3b2
feat(billing): implement promo usage reservation and rollback mechani…
Dobrunia f5b405f
refactor(billing): simplify promo code retrieval and improve index in…
Dobrunia 10f578e
feat(billing): enhance promo validation and payment processing logic …
Dobrunia 7945cdb
refactor(billing): remove unused index initialization logic from prom…
Dobrunia d782e2b
refactor(billing): rename and restructure promo code application logi…
Dobrunia e9ed561
refactor(billing): rename applyPromoCode to verifyPromoCode and updat…
Dobrunia b0ef27a
refactor(billing): streamline promo code pricing calculation logic fo…
Dobrunia 627f8fb
refactor(migrations): update index dropping syntax for promo codes an…
Dobrunia cf31f71
refactor(billing): enhance CloudPayments test suite with additional m…
Dobrunia e0af65c
refactor(billing): improve promo code usage creation logic and enhanc…
Dobrunia 5693467
refactor(billing): remove unused promo code fields and simplify payme…
Dobrunia 158f2ef
refactor(billing): enhance promo billing test suite with improved fix…
Dobrunia 02d13a2
fix (tests)
Dobrunia 910ac27
refactor(billing): enhance CloudPayments webhook handling and improve…
Dobrunia 2e5ad23
test(billing): add tests for subscription renewal data retrieval and …
Dobrunia 36af36d
refactor(billing): remove unused promo code fields and update charge …
Dobrunia 9c2e51e
test(billing): add tests for card-link checksum handling and amount v…
Dobrunia b08177c
refactor(billing): remove validation for discounted recurrent amounts…
Dobrunia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -88,7 +89,7 @@ | |
| return router; | ||
| } | ||
|
|
||
| /** | ||
|
Check warning on line 92 in src/billing/cloudpayments.ts
|
||
| * Generates invoice id for payment | ||
| * | ||
| * @param tariffPlan - tariff plan to generate invoice id | ||
|
|
@@ -101,9 +102,15 @@ | |
| } | ||
|
|
||
| /** | ||
| * Route to confirm the correctness of a user's payment | ||
| * Route to confirm that CloudPayments may process the payment. | ||
| * https://developers.cloudpayments.ru/#check | ||
| * | ||
| * This route handles both the first widget payment and later subscription charges. | ||
| * The first widget payment sends signed Data with billing intent and optional promo. | ||
| * Later recurrent charges may arrive without Data; in that case we resolve the | ||
| * workspace and current plan by SubscriptionId and validate the amount against | ||
| * the full plan price. | ||
| * | ||
| * @param req - cloudpayments request with payment details | ||
| * @param res - check result code | ||
| */ | ||
|
|
@@ -141,7 +148,7 @@ | |
|
|
||
| let workspace: WorkspaceModel; | ||
| let member: ConfirmedMemberDBScheme; | ||
| let plan: PlanDBScheme; | ||
| let plan: PlanModel; | ||
| let planId: string; | ||
|
|
||
| const { workspaceId, userId, tariffPlanId } = data; | ||
|
|
@@ -160,12 +167,48 @@ | |
| } | ||
|
|
||
| const recurrentPaymentSettings = data.cloudPayments?.recurrent; | ||
| let promoPricing; | ||
|
|
||
| /** | ||
| * Revalidate promo before accepting payment. | ||
| * | ||
| * Amount check uses server-side pricing; usage is recorded later in /pay | ||
| * after workspace plan is updated successfully. | ||
| */ | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| * 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 isRightAmount = +body.Amount === plan.monthlyCharge || recurrentPaymentSettings?.startDate; | ||
| 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); | ||
|
|
@@ -205,7 +248,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); | ||
|
|
||
| res.json({ | ||
| code: CheckCodes.SUCCESS, | ||
|
|
@@ -303,6 +346,22 @@ | |
| return; | ||
| } | ||
|
|
||
| if (data.promo && !data.isCardLinkOperation) { | ||
| try { | ||
| const promoCodeService = new PromoCodeService(req.context.factories); | ||
|
|
||
| await promoCodeService.createUsage({ | ||
| promoCodeId: data.promo.id, | ||
| userId: data.userId, | ||
| workspaceId: workspace._id, | ||
| plan: tariffPlan, | ||
| utm: data.promo.utm, | ||
| }); | ||
| } catch (error) { | ||
| console.error('[Billing / Pay] Failed to record promo usage after plan change', error); | ||
| } | ||
| } | ||
|
|
||
| // let accountId = workspace.accountId; | ||
|
|
||
| /* | ||
|
|
@@ -442,7 +501,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 = ''; | ||
|
|
||
|
|
@@ -555,7 +614,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); | ||
|
|
||
| res.json({ | ||
| code: FailCodes.SUCCESS, | ||
|
|
@@ -563,9 +622,15 @@ | |
| } | ||
|
|
||
| /** | ||
| * Route is executed if the status of the recurring payment subscription has been changed. | ||
| * Route executed when a CloudPayments subscription status changes. | ||
| * https://developers.cloudpayments.ru/#recurrent | ||
| * | ||
| * This notification is about the subscription entity, not a replacement for | ||
| * /check or /pay transaction notifications. CloudPayments identifies which | ||
| * charge number this is via SuccessfulTransactionsNumber and sends the | ||
| * subscription Id; our transaction handlers use that Id to find the workspace. | ||
| * Promo data is not expected here and is not applied to subscription renewals. | ||
| * | ||
| * @param req - cloudpayments request with subscription details | ||
| * @param res - result code | ||
| */ | ||
|
|
@@ -737,7 +802,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 { | ||
| res.json({ | ||
| code: errorCode, | ||
| }); | ||
|
|
@@ -751,7 +816,17 @@ | |
| } | ||
|
|
||
| /** | ||
| * Parses request body and returns data from it | ||
| * Parses CloudPayments request body into the signed billing intent. | ||
| * | ||
| * First widget payments include Data.checksum generated by composePayment; the | ||
| * checksum is the only trusted source for workspaceId, tariffPlanId, userId, | ||
| * shouldSaveCard, and promo id. Unsigned widget Data fields must not override it. | ||
| * | ||
| * Recurrent subscription renewals usually do not include Data. For those | ||
| * requests CloudPayments sends SubscriptionId and AccountId, so we restore the | ||
| * workspace and current plan by SubscriptionId. Because there is no signed promo | ||
| * in this path, data.promo is intentionally absent: promo discounts are applied | ||
| * only to the first widget payment, while renewals are charged at full plan price. | ||
| * | ||
| * @param req - request with necessary data | ||
| */ | ||
|
|
@@ -760,15 +835,34 @@ | |
| const body: CheckRequest | PayRequest | FailRequest = req.body; | ||
|
|
||
| /** | ||
| * If Data is not presented in body means there is a recurring payment | ||
| * Data field is presented only in one-time payment requests or subscription initial request | ||
| * If Data is absent, this is a subscription renewal (or check/pay identified by SubscriptionId). | ||
| * Renewals do not carry promo: discount was applied only on the first widget payment. | ||
| */ | ||
| 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 } : {}), | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| ...checksumService.parseAndVerifyChecksum(parsedData.checksum), | ||
| ...parsedData, | ||
| ...checksumData, | ||
| ...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}), | ||
| isCardLinkOperation: false, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -802,7 +896,7 @@ | |
| promise.catch(e => console.error('Error while sending message to Telegram: ' + e)); | ||
| } | ||
|
|
||
| /** | ||
|
Check warning on line 899 in src/billing/cloudpayments.ts
|
||
| * Parses body and returns card data | ||
| * @param request - request body to parse | ||
| */ | ||
|
|
@@ -826,8 +920,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 | ||
|
|
@@ -836,9 +931,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, | ||
| }; | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.