@@ -17,7 +17,13 @@ import checksumService from '../../../../src/utils/checksumService';
1717import { mainRequest , transactionId } from '../../billingMocks' ;
1818import type { Global } from '@jest/types' ;
1919
20- declare var global : Global . Global ;
20+ declare var global : Global . Global ;
21+
22+ /** Plan price before promo (RUB) */
23+ const PLAN_MONTHLY_CHARGE = 1000 ;
24+
25+ /** Expected charge after 25% promo discount (RUB) */
26+ const PROMO_DISCOUNTED_CHARGE = 750 ;
2127
2228describe ( 'Promo billing webhooks' , ( ) => {
2329 let accountsDb : Db ;
@@ -44,85 +50,101 @@ describe('Promo billing webhooks', () => {
4450 promoCodeUsagesCollection = accountsDb . collection < Omit < PromoCodeUsageDBScheme , '_id' > > ( 'promoCodeUsages' ) ;
4551 } ) ;
4652
47- beforeEach ( async ( ) => {
48- const currentPlanId = ( await plansCollection . insertOne ( {
49- name : 'CurrentTestPlan' ,
50- monthlyCharge : 10 ,
51- monthlyChargeCurrency : 'RUB' ,
52- eventsLimit : 1000 ,
53- isDefault : false ,
54- } ) ) . insertedId ;
55-
56- const workspaceId = ( await workspacesCollection . insertOne ( {
57- name : 'PromoBillingTest' ,
58- accountId : '123' ,
59- tariffPlanId : currentPlanId ,
60- } as WorkspaceDBScheme ) ) . insertedId ;
61- const workspaceResult = await workspacesCollection . findOne ( { _id : workspaceId } ) ;
62-
63- if ( ! workspaceResult ) {
64- throw new Error ( 'Failed to create workspace' ) ;
65- }
66-
67- workspace = workspaceResult as WorkspaceDBScheme ;
68-
69- const adminId = ( await usersCollection . insertOne ( {
70- email : 'admin@promo-billing.test' ,
71- } ) ) . insertedId ;
72- const adminResult = await usersCollection . findOne ( { _id : adminId } ) ;
53+ afterEach ( async ( ) => {
54+ await accountsDb . dropDatabase ( ) ;
55+ } ) ;
7356
74- if ( ! adminResult ) {
75- throw new Error ( 'Failed to create admin' ) ;
57+ /**
58+ * Insert a document and load the persisted record.
59+ * Throws if insert or read fails — keeps fixture setup explicit in tests.
60+ */
61+ async function insertAndLoad < T extends { _id ?: ObjectId } , R extends T > (
62+ collection : Collection < T > ,
63+ document : Omit < T , '_id' > ,
64+ errorMessage : string
65+ ) : Promise < R > {
66+ const insertedId = ( await collection . insertOne ( document as T ) ) . insertedId ;
67+ const result = await collection . findOne ( { _id : insertedId } as Partial < T > ) ;
68+
69+ if ( ! result ) {
70+ throw new Error ( errorMessage ) ;
7671 }
7772
78- admin = adminResult as UserDBScheme ;
73+ return result as R ;
74+ }
7975
80- const planToChangeId = ( await plansCollection . insertOne ( {
81- name : 'PromoBasic' ,
82- monthlyCharge : 1000 ,
76+ /**
77+ * Seed workspace, admin, target plan and promo code for promo billing tests.
78+ *
79+ * Workspace starts on a cheap current plan; payment targets `planToChange` (1000 RUB).
80+ * Promo `SAVE25` gives 25% off → expected first charge is 750 RUB.
81+ * Admin is added to workspace team so check/pay webhooks pass membership checks.
82+ */
83+ async function seedPromoBillingFixtures ( ) : Promise < void > {
84+ const currentPlanId = ( await plansCollection . insertOne ( {
85+ name : 'CurrentTestPlan' ,
86+ monthlyCharge : 10 ,
8387 monthlyChargeCurrency : 'RUB' ,
84- eventsLimit : 10000 ,
88+ eventsLimit : 1000 ,
8589 isDefault : false ,
8690 } ) ) . insertedId ;
87- const planToChangeResult = await plansCollection . findOne ( { _id : planToChangeId } ) ;
88-
89- if ( ! planToChangeResult ) {
90- throw new Error ( 'Failed to create planToChange' ) ;
91- }
9291
93- planToChange = planToChangeResult as PlanDBScheme ;
94-
95- const promoCodeId = ( await promoCodesCollection . insertOne ( {
96- value : 'SAVE25' ,
97- benefit : {
98- type : 'percent_discount' ,
99- percent : 25 ,
92+ workspace = await insertAndLoad (
93+ workspacesCollection ,
94+ {
95+ name : 'PromoBillingTest' ,
96+ accountId : '123' ,
97+ tariffPlanId : currentPlanId ,
98+ } as WorkspaceDBScheme ,
99+ 'Failed to create workspace'
100+ ) ;
101+
102+ admin = await insertAndLoad (
103+ usersCollection ,
104+ { email : 'admin@promo-billing.test' } ,
105+ 'Failed to create admin'
106+ ) ;
107+
108+ planToChange = await insertAndLoad (
109+ plansCollection ,
110+ {
111+ name : 'PromoBasic' ,
112+ monthlyCharge : PLAN_MONTHLY_CHARGE ,
113+ monthlyChargeCurrency : 'RUB' ,
114+ eventsLimit : 10000 ,
115+ isDefault : false ,
100116 } ,
101- createdAt : new Date ( ) ,
102- updatedAt : new Date ( ) ,
103- createdBy : admin . _id . toString ( ) ,
104- } ) ) . insertedId ;
105- const promoCodeResult = await promoCodesCollection . findOne ( { _id : promoCodeId } ) ;
106-
107- if ( ! promoCodeResult ) {
108- throw new Error ( 'Failed to create promo code' ) ;
109- }
110-
111- promoCode = promoCodeResult as PromoCodeDBScheme ;
117+ 'Failed to create planToChange'
118+ ) ;
119+
120+ promoCode = await insertAndLoad (
121+ promoCodesCollection ,
122+ {
123+ value : 'SAVE25' ,
124+ benefit : {
125+ type : 'percent_discount' ,
126+ percent : 25 ,
127+ } ,
128+ createdAt : new Date ( ) ,
129+ updatedAt : new Date ( ) ,
130+ createdBy : admin . _id . toString ( ) ,
131+ } ,
132+ 'Failed to create promo code'
133+ ) ;
112134
113135 const team = accountsDb . collection < Omit < ConfirmedMemberDBScheme , '_id' > > ( `team:${ workspace . _id . toString ( ) } ` ) ;
114136
115137 await team . insertOne ( {
116138 userId : admin . _id ,
117139 isAdmin : true ,
118140 } ) ;
119- } ) ;
120-
121- afterEach ( async ( ) => {
122- await accountsDb . dropDatabase ( ) ;
123- } ) ;
141+ }
124142
125- async function buildPromoChecksum ( ) {
143+ /**
144+ * Checksum from composePayment with promo id embedded.
145+ * CloudPayments check/pay handlers revalidate amount against this promo.
146+ */
147+ async function buildPromoChecksum ( ) : Promise < string > {
126148 return checksumService . generateChecksum ( {
127149 workspaceId : workspace . _id . toString ( ) ,
128150 userId : admin . _id . toString ( ) ,
@@ -135,112 +157,117 @@ describe('Promo billing webhooks', () => {
135157 } ) ;
136158 }
137159
138- describe ( '/billing/check' , ( ) => {
139- it ( 'should accept discounted amount when promo is valid' , async ( ) => {
140- const data : CheckRequest = {
141- ...mainRequest ,
142- Amount : '750' ,
143- Currency : Currency . RUB ,
144- Data : JSON . stringify ( {
145- checksum : await buildPromoChecksum ( ) ,
146- cloudPayments : {
147- recurrent : {
148- interval : 'Month' ,
149- period : 1 ,
150- amount : 1000 ,
151- } ,
160+ /**
161+ * Build /billing/check request with promo checksum and recurrent subscription metadata.
162+ */
163+ async function buildPromoCheckRequest ( chargeAmount : number ) : Promise < CheckRequest > {
164+ return {
165+ ...mainRequest ,
166+ Amount : chargeAmount . toString ( ) ,
167+ Currency : Currency . RUB ,
168+ Data : JSON . stringify ( {
169+ checksum : await buildPromoChecksum ( ) ,
170+ cloudPayments : {
171+ recurrent : {
172+ interval : 'Month' ,
173+ period : 1 ,
174+ amount : PLAN_MONTHLY_CHARGE ,
152175 } ,
153- } ) ,
154- } ;
155-
156- const apiResponse = await apiInstance . post ( '/billing/check' , data ) ;
157- const createdBusinessOperation = await businessOperationsCollection . findOne ( {
158- transactionId : transactionId . toString ( ) ,
159- } ) ;
176+ } ,
177+ } ) ,
178+ } ;
179+ }
160180
161- expect ( apiResponse . data . code ) . toBe ( CheckCodes . SUCCESS ) ;
162- expect ( createdBusinessOperation ?. status ) . toBe ( BusinessOperationStatus . Pending ) ;
163- } ) ;
181+ beforeEach ( async ( ) => {
182+ await seedPromoBillingFixtures ( ) ;
183+ } ) ;
164184
165- it ( 'should reject full plan amount when promo expects discounted charge' , async ( ) => {
166- const data : CheckRequest = {
167- ...mainRequest ,
168- Amount : '1000' ,
169- Currency : Currency . RUB ,
170- Data : JSON . stringify ( {
171- checksum : await buildPromoChecksum ( ) ,
172- cloudPayments : {
173- recurrent : {
174- interval : 'Month' ,
175- period : 1 ,
176- amount : 1000 ,
177- } ,
178- } ,
179- } ) ,
180- } ;
185+ describe ( '/billing/check' , ( ) => {
186+ describe ( 'with promo code' , ( ) => {
187+ it ( 'should accept discounted charge amount and create pending business operation' , async ( ) => {
188+ const apiResponse = await apiInstance . post ( '/billing/check' , await buildPromoCheckRequest ( PROMO_DISCOUNTED_CHARGE ) ) ;
189+ const createdBusinessOperation = await businessOperationsCollection . findOne ( {
190+ transactionId : transactionId . toString ( ) ,
191+ } ) ;
192+
193+ expect ( apiResponse . data . code ) . toBe ( CheckCodes . SUCCESS ) ;
194+ /**
195+ * /billing/check only validates payment and registers intent.
196+ * Business operation stays Pending until /billing/pay confirms the charge.
197+ */
198+ expect ( createdBusinessOperation ?. status ) . toBe ( BusinessOperationStatus . Pending ) ;
199+ } ) ;
181200
182- const apiResponse = await apiInstance . post ( '/billing/check' , data ) ;
201+ it ( 'should return WRONG_AMOUNT when charge equals full plan price instead of promo discount' , async ( ) => {
202+ const apiResponse = await apiInstance . post ( '/billing/check' , await buildPromoCheckRequest ( PLAN_MONTHLY_CHARGE ) ) ;
183203
184- expect ( apiResponse . data . code ) . toBe ( CheckCodes . WRONG_AMOUNT ) ;
204+ expect ( apiResponse . data . code ) . toBe ( CheckCodes . WRONG_AMOUNT ) ;
205+ } ) ;
185206 } ) ;
186207 } ) ;
187208
188209 describe ( '/billing/pay' , ( ) => {
189- let validPayRequestData : PayRequest ;
190-
191- beforeEach ( async ( ) => {
192- await businessOperationsCollection . insertOne ( {
193- transactionId : transactionId . toString ( ) ,
194- type : BusinessOperationType . WorkspacePlanPurchase ,
195- status : BusinessOperationStatus . Pending ,
196- dtCreated : new Date ( ) ,
197- payload : {
198- workspaceId : workspace . _id ,
199- amount : 75000 ,
200- currency : Currency . RUB ,
201- userId : admin . _id ,
202- tariffPlanId : planToChange . _id ,
203- } ,
210+ describe ( 'with promo code' , ( ) => {
211+ let validPayRequestData : PayRequest ;
212+
213+ beforeEach ( async ( ) => {
214+ /**
215+ * /billing/pay expects check webhook to have already created a Pending operation
216+ * for the same transactionId — mirrors real CloudPayments two-step flow.
217+ */
218+ await businessOperationsCollection . insertOne ( {
219+ transactionId : transactionId . toString ( ) ,
220+ type : BusinessOperationType . WorkspacePlanPurchase ,
221+ status : BusinessOperationStatus . Pending ,
222+ dtCreated : new Date ( ) ,
223+ payload : {
224+ workspaceId : workspace . _id ,
225+ amount : PROMO_DISCOUNTED_CHARGE * 100 ,
226+ currency : Currency . RUB ,
227+ userId : admin . _id ,
228+ tariffPlanId : planToChange . _id ,
229+ } ,
230+ } ) ;
231+
232+ validPayRequestData = {
233+ Amount : PROMO_DISCOUNTED_CHARGE . toString ( ) ,
234+ CardExpDate : '06/25' ,
235+ CardFirstSix : '578946' ,
236+ CardLastFour : '5367' ,
237+ CardType : CardType . VISA ,
238+ Currency : Currency . RUB ,
239+ DateTime : new Date ( ) ,
240+ GatewayName : 'CodeX bank' ,
241+ OperationType : OperationType . PAYMENT ,
242+ Status : OperationStatus . COMPLETED ,
243+ TestMode : false ,
244+ TransactionId : transactionId ,
245+ Token : '123123' ,
246+ IssuerBankCountry : 'US' ,
247+ Data : JSON . stringify ( {
248+ checksum : await buildPromoChecksum ( ) ,
249+ } ) ,
250+ } ;
204251 } ) ;
205252
206- validPayRequestData = {
207- Amount : '750' ,
208- CardExpDate : '06/25' ,
209- CardFirstSix : '578946' ,
210- CardLastFour : '5367' ,
211- CardType : CardType . VISA ,
212- Currency : Currency . RUB ,
213- DateTime : new Date ( ) ,
214- GatewayName : 'CodeX bank' ,
215- OperationType : OperationType . PAYMENT ,
216- Status : OperationStatus . COMPLETED ,
217- TestMode : false ,
218- TransactionId : transactionId ,
219- Token : '123123' ,
220- IssuerBankCountry : 'US' ,
221- Data : JSON . stringify ( {
222- checksum : await buildPromoChecksum ( ) ,
223- } ) ,
224- } ;
225- } ) ;
253+ it ( 'should change plan and record promo usage after successful payment' , async ( ) => {
254+ const apiResponse = await apiInstance . post ( '/billing/pay' , validPayRequestData ) ;
255+
256+ const updatedWorkspace = await workspacesCollection . findOne ( { _id : workspace . _id } ) ;
257+ const promoUsage = await promoCodeUsagesCollection . findOne ( { promoCodeId : promoCode . _id } ) ;
226258
227- it ( 'should change plan and record promo usage after successful payment' , async ( ) => {
228- const apiResponse = await apiInstance . post ( '/billing/pay' , validPayRequestData ) ;
229-
230- const updatedWorkspace = await workspacesCollection . findOne ( { _id : workspace . _id } ) ;
231- const promoUsage = await promoCodeUsagesCollection . findOne ( { promoCodeId : promoCode . _id } ) ;
232-
233- expect ( apiResponse . data . code ) . toBe ( PayCodes . SUCCESS ) ;
234- expect ( updatedWorkspace ?. tariffPlanId . toString ( ) ) . toBe ( planToChange . _id . toString ( ) ) ;
235- expect ( promoUsage ) . toMatchObject ( {
236- promoCodeId : promoCode . _id ,
237- userId : admin . _id . toString ( ) ,
238- workspaceId : workspace . _id ,
239- planId : planToChange . _id ,
240- benefitType : 'percent_discount' ,
241- originalAmount : 1000 ,
242- finalAmount : 750 ,
243- discountAmount : 250 ,
259+ expect ( apiResponse . data . code ) . toBe ( PayCodes . SUCCESS ) ;
260+ expect ( updatedWorkspace ?. tariffPlanId . toString ( ) ) . toBe ( planToChange . _id . toString ( ) ) ;
261+ expect ( promoUsage ) . toMatchObject ( {
262+ promoCodeId : promoCode . _id ,
263+ userId : admin . _id . toString ( ) ,
264+ workspaceId : workspace . _id ,
265+ planId : planToChange . _id ,
266+ benefitType : 'percent_discount' ,
267+ originalAmount : PLAN_MONTHLY_CHARGE ,
268+ finalAmount : PROMO_DISCOUNTED_CHARGE ,
269+ discountAmount : PLAN_MONTHLY_CHARGE - PROMO_DISCOUNTED_CHARGE ,
270+ } ) ;
244271 } ) ;
245272 } ) ;
246273 } ) ;
0 commit comments