@misterhomer1992/miit-bot-payment 1.1.6 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/dist/config/ConfigurationManager.d.ts +64 -0
  2. package/dist/config/ConfigurationManager.d.ts.map +1 -0
  3. package/dist/config/ConfigurationManager.js +144 -0
  4. package/dist/config/ConfigurationManager.js.map +1 -0
  5. package/dist/config/defaults.d.ts +18 -0
  6. package/dist/config/defaults.d.ts.map +1 -0
  7. package/dist/config/defaults.js +26 -0
  8. package/dist/config/defaults.js.map +1 -0
  9. package/dist/config/environment.d.ts +38 -0
  10. package/dist/config/environment.d.ts.map +1 -0
  11. package/dist/config/environment.js +91 -0
  12. package/dist/config/environment.js.map +1 -0
  13. package/dist/config/index.d.ts +5 -0
  14. package/dist/config/index.d.ts.map +1 -0
  15. package/dist/config/index.js +18 -0
  16. package/dist/config/index.js.map +1 -0
  17. package/dist/config/types.d.ts +53 -0
  18. package/dist/config/types.d.ts.map +1 -0
  19. package/dist/config/types.js +3 -0
  20. package/dist/config/types.js.map +1 -0
  21. package/dist/index.d.ts +21 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +23 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/modules/cache/InMemoryCache.d.ts +17 -0
  26. package/dist/modules/cache/InMemoryCache.d.ts.map +1 -0
  27. package/dist/modules/cache/InMemoryCache.js +77 -0
  28. package/dist/modules/cache/InMemoryCache.js.map +1 -0
  29. package/dist/modules/cache/index.d.ts +3 -0
  30. package/dist/modules/cache/index.d.ts.map +1 -0
  31. package/dist/modules/cache/index.js +19 -0
  32. package/dist/modules/cache/index.js.map +1 -0
  33. package/dist/modules/cache/types.d.ts +52 -0
  34. package/dist/modules/cache/types.d.ts.map +1 -0
  35. package/dist/modules/cache/types.js +3 -0
  36. package/dist/modules/cache/types.js.map +1 -0
  37. package/dist/modules/errors/index.d.ts +2 -0
  38. package/dist/modules/errors/index.d.ts.map +1 -0
  39. package/dist/modules/errors/index.js +19 -0
  40. package/dist/modules/errors/index.js.map +1 -0
  41. package/dist/modules/errors/types.d.ts +112 -0
  42. package/dist/modules/errors/types.d.ts.map +1 -0
  43. package/dist/modules/errors/types.js +174 -0
  44. package/dist/modules/errors/types.js.map +1 -0
  45. package/dist/modules/payments/api.d.ts +63 -1
  46. package/dist/modules/payments/api.d.ts.map +1 -1
  47. package/dist/modules/payments/api.js +103 -1
  48. package/dist/modules/payments/api.js.map +1 -1
  49. package/dist/modules/payments/const.d.ts.map +1 -1
  50. package/dist/modules/payments/const.js +1 -0
  51. package/dist/modules/payments/const.js.map +1 -1
  52. package/dist/modules/payments/index.d.ts +8 -0
  53. package/dist/modules/payments/index.d.ts.map +1 -1
  54. package/dist/modules/payments/index.js +8 -0
  55. package/dist/modules/payments/index.js.map +1 -1
  56. package/dist/modules/payments/service.d.ts +42 -2
  57. package/dist/modules/payments/service.d.ts.map +1 -1
  58. package/dist/modules/payments/service.js +132 -3
  59. package/dist/modules/payments/service.js.map +1 -1
  60. package/dist/modules/payments/subscription-check-webhook.handler.d.ts +85 -0
  61. package/dist/modules/payments/subscription-check-webhook.handler.d.ts.map +1 -0
  62. package/dist/modules/payments/subscription-check-webhook.handler.js +155 -0
  63. package/dist/modules/payments/subscription-check-webhook.handler.js.map +1 -0
  64. package/dist/modules/payments/subscription-check-webhook.service.d.ts +59 -0
  65. package/dist/modules/payments/subscription-check-webhook.service.d.ts.map +1 -0
  66. package/dist/modules/payments/subscription-check-webhook.service.js +330 -0
  67. package/dist/modules/payments/subscription-check-webhook.service.js.map +1 -0
  68. package/dist/modules/payments/subscription-check-webhook.types.d.ts +25 -0
  69. package/dist/modules/payments/subscription-check-webhook.types.d.ts.map +1 -0
  70. package/dist/modules/payments/subscription-check-webhook.types.js +3 -0
  71. package/dist/modules/payments/subscription-check-webhook.types.js.map +1 -0
  72. package/dist/modules/payments/types.d.ts +69 -2
  73. package/dist/modules/payments/types.d.ts.map +1 -1
  74. package/dist/modules/payments/utils.d.ts +151 -5
  75. package/dist/modules/payments/utils.d.ts.map +1 -1
  76. package/dist/modules/payments/utils.js +253 -9
  77. package/dist/modules/payments/utils.js.map +1 -1
  78. package/dist/modules/payments/wayforpay.service.d.ts +39 -0
  79. package/dist/modules/payments/wayforpay.service.d.ts.map +1 -0
  80. package/dist/modules/payments/wayforpay.service.js +217 -0
  81. package/dist/modules/payments/wayforpay.service.js.map +1 -0
  82. package/dist/modules/payments/wayforpay.types.d.ts +115 -0
  83. package/dist/modules/payments/wayforpay.types.d.ts.map +1 -0
  84. package/dist/modules/payments/wayforpay.types.js +3 -0
  85. package/dist/modules/payments/wayforpay.types.js.map +1 -0
  86. package/dist/modules/payments/webhook.handler.d.ts +98 -0
  87. package/dist/modules/payments/webhook.handler.d.ts.map +1 -0
  88. package/dist/modules/payments/webhook.handler.js +153 -0
  89. package/dist/modules/payments/webhook.handler.js.map +1 -0
  90. package/dist/modules/payments/webhook.service.d.ts +99 -0
  91. package/dist/modules/payments/webhook.service.d.ts.map +1 -0
  92. package/dist/modules/payments/webhook.service.js +672 -0
  93. package/dist/modules/payments/webhook.service.js.map +1 -0
  94. package/dist/modules/payments/webhook.types.d.ts +35 -0
  95. package/dist/modules/payments/webhook.types.d.ts.map +1 -0
  96. package/dist/modules/payments/webhook.types.js +3 -0
  97. package/dist/modules/payments/webhook.types.js.map +1 -0
  98. package/dist/modules/subscription/change.service.d.ts +80 -0
  99. package/dist/modules/subscription/change.service.d.ts.map +1 -0
  100. package/dist/modules/subscription/change.service.js +226 -0
  101. package/dist/modules/subscription/change.service.js.map +1 -0
  102. package/dist/modules/subscription/index.d.ts +2 -0
  103. package/dist/modules/subscription/index.d.ts.map +1 -1
  104. package/dist/modules/subscription/index.js +2 -0
  105. package/dist/modules/subscription/index.js.map +1 -1
  106. package/dist/modules/subscription/service.d.ts +8 -1
  107. package/dist/modules/subscription/service.d.ts.map +1 -1
  108. package/dist/modules/subscription/service.js +59 -2
  109. package/dist/modules/subscription/service.js.map +1 -1
  110. package/dist/modules/subscription/status-check.handler.d.ts +117 -0
  111. package/dist/modules/subscription/status-check.handler.d.ts.map +1 -0
  112. package/dist/modules/subscription/status-check.handler.js +164 -0
  113. package/dist/modules/subscription/status-check.handler.js.map +1 -0
  114. package/dist/modules/subscription/types.d.ts +37 -1
  115. package/dist/modules/subscription/types.d.ts.map +1 -1
  116. package/dist/modules/subscriptionPlan/const.d.ts +5 -0
  117. package/dist/modules/subscriptionPlan/const.d.ts.map +1 -0
  118. package/dist/modules/subscriptionPlan/const.js +106 -0
  119. package/dist/modules/subscriptionPlan/const.js.map +1 -0
  120. package/dist/modules/subscriptionPlan/index.d.ts +5 -0
  121. package/dist/modules/subscriptionPlan/index.d.ts.map +1 -0
  122. package/dist/modules/subscriptionPlan/index.js +21 -0
  123. package/dist/modules/subscriptionPlan/index.js.map +1 -0
  124. package/dist/modules/subscriptionPlan/repository.d.ts +22 -0
  125. package/dist/modules/subscriptionPlan/repository.d.ts.map +1 -0
  126. package/dist/modules/subscriptionPlan/repository.js +95 -0
  127. package/dist/modules/subscriptionPlan/repository.js.map +1 -0
  128. package/dist/modules/subscriptionPlan/service.d.ts +21 -0
  129. package/dist/modules/subscriptionPlan/service.d.ts.map +1 -0
  130. package/dist/modules/subscriptionPlan/service.js +128 -0
  131. package/dist/modules/subscriptionPlan/service.js.map +1 -0
  132. package/dist/modules/subscriptionPlan/types.d.ts +40 -0
  133. package/dist/modules/subscriptionPlan/types.d.ts.map +1 -0
  134. package/dist/modules/subscriptionPlan/types.js +3 -0
  135. package/dist/modules/subscriptionPlan/types.js.map +1 -0
  136. package/dist/modules/token/const.d.ts +7 -0
  137. package/dist/modules/token/const.d.ts.map +1 -0
  138. package/dist/modules/token/const.js +66 -0
  139. package/dist/modules/token/const.js.map +1 -0
  140. package/dist/modules/token/index.d.ts +4 -0
  141. package/dist/modules/token/index.d.ts.map +1 -0
  142. package/dist/modules/token/index.js +20 -0
  143. package/dist/modules/token/index.js.map +1 -0
  144. package/dist/modules/token/service.d.ts +46 -0
  145. package/dist/modules/token/service.d.ts.map +1 -0
  146. package/dist/modules/token/service.js +249 -0
  147. package/dist/modules/token/service.js.map +1 -0
  148. package/dist/modules/token/types.d.ts +109 -0
  149. package/dist/modules/token/types.d.ts.map +1 -0
  150. package/dist/modules/token/types.js +3 -0
  151. package/dist/modules/token/types.js.map +1 -0
  152. package/dist/modules/tokenPack/const.d.ts +4 -0
  153. package/dist/modules/tokenPack/const.d.ts.map +1 -0
  154. package/dist/modules/tokenPack/const.js +10 -0
  155. package/dist/modules/tokenPack/const.js.map +1 -0
  156. package/dist/modules/tokenPack/index.d.ts +5 -0
  157. package/dist/modules/tokenPack/index.d.ts.map +1 -0
  158. package/dist/modules/tokenPack/index.js +21 -0
  159. package/dist/modules/tokenPack/index.js.map +1 -0
  160. package/dist/modules/tokenPack/repository.d.ts +32 -0
  161. package/dist/modules/tokenPack/repository.d.ts.map +1 -0
  162. package/dist/modules/tokenPack/repository.js +103 -0
  163. package/dist/modules/tokenPack/repository.js.map +1 -0
  164. package/dist/modules/tokenPack/service.d.ts +28 -0
  165. package/dist/modules/tokenPack/service.d.ts.map +1 -0
  166. package/dist/modules/tokenPack/service.js +106 -0
  167. package/dist/modules/tokenPack/service.js.map +1 -0
  168. package/dist/modules/tokenPack/types.d.ts +124 -0
  169. package/dist/modules/tokenPack/types.d.ts.map +1 -0
  170. package/dist/modules/tokenPack/types.js +3 -0
  171. package/dist/modules/tokenPack/types.js.map +1 -0
  172. package/package.json +9 -5
  173. package/src/config/ConfigurationManager.ts +159 -0
  174. package/src/config/defaults.ts +27 -0
  175. package/src/config/environment.ts +94 -0
  176. package/src/config/index.ts +22 -0
  177. package/src/config/types.ts +56 -0
  178. package/src/index.ts +29 -0
  179. package/src/modules/cache/InMemoryCache.ts +98 -0
  180. package/src/modules/cache/index.ts +2 -0
  181. package/src/modules/cache/types.ts +60 -0
  182. package/src/modules/cancellableAPI/utils.ts +60 -0
  183. package/src/modules/errors/index.ts +16 -0
  184. package/src/modules/errors/types.ts +201 -0
  185. package/src/modules/invoice/const.ts +7 -0
  186. package/src/modules/invoice/index.ts +4 -0
  187. package/src/modules/invoice/repository.ts +52 -0
  188. package/src/modules/invoice/service.ts +44 -0
  189. package/src/modules/invoice/types.ts +47 -0
  190. package/src/modules/logger/types.ts +8 -0
  191. package/src/modules/network/utils.ts +24 -0
  192. package/src/modules/payments/api.ts +289 -0
  193. package/src/modules/payments/const.ts +11 -0
  194. package/src/modules/payments/index.ts +14 -0
  195. package/src/modules/payments/repository.ts +125 -0
  196. package/src/modules/payments/service.test.ts +400 -0
  197. package/src/modules/payments/service.ts +365 -0
  198. package/src/modules/payments/subscription-check-webhook.handler.integration.test.ts +935 -0
  199. package/src/modules/payments/subscription-check-webhook.handler.ts +211 -0
  200. package/src/modules/payments/subscription-check-webhook.service.ts +398 -0
  201. package/src/modules/payments/subscription-check-webhook.types.ts +29 -0
  202. package/src/modules/payments/types.ts +193 -0
  203. package/src/modules/payments/utils.ts +428 -0
  204. package/src/modules/payments/wayforpay.service.test.ts +375 -0
  205. package/src/modules/payments/wayforpay.service.ts +284 -0
  206. package/src/modules/payments/wayforpay.types.ts +138 -0
  207. package/src/modules/payments/webhook.handler.integration.test.ts +975 -0
  208. package/src/modules/payments/webhook.handler.ts +219 -0
  209. package/src/modules/payments/webhook.service.ts +812 -0
  210. package/src/modules/payments/webhook.types.ts +38 -0
  211. package/src/modules/subscription/change.service.ts +317 -0
  212. package/src/modules/subscription/const.ts +9 -0
  213. package/src/modules/subscription/index.ts +5 -0
  214. package/src/modules/subscription/repository.ts +277 -0
  215. package/src/modules/subscription/service.test.ts +665 -0
  216. package/src/modules/subscription/service.ts +328 -0
  217. package/src/modules/subscription/status-check.handler.ts +254 -0
  218. package/src/modules/subscription/types.ts +267 -0
  219. package/src/modules/subscription/utils.ts +5 -0
  220. package/src/modules/subscriptionPlan/const.ts +106 -0
  221. package/src/modules/subscriptionPlan/index.ts +4 -0
  222. package/src/modules/subscriptionPlan/repository.ts +129 -0
  223. package/src/modules/subscriptionPlan/service.test.ts +401 -0
  224. package/src/modules/subscriptionPlan/service.ts +148 -0
  225. package/src/modules/subscriptionPlan/types.ts +67 -0
  226. package/src/modules/token/const.ts +64 -0
  227. package/src/modules/token/index.ts +3 -0
  228. package/src/modules/token/service.test.ts +499 -0
  229. package/src/modules/token/service.ts +297 -0
  230. package/src/modules/token/types.ts +124 -0
  231. package/src/modules/tokenPack/const.ts +9 -0
  232. package/src/modules/tokenPack/index.ts +4 -0
  233. package/src/modules/tokenPack/repository.ts +144 -0
  234. package/src/modules/tokenPack/service.ts +119 -0
  235. package/src/modules/tokenPack/types.ts +131 -0
  236. package/src/modules/user/index.ts +3 -0
  237. package/src/modules/user/types.ts +143 -0
  238. package/src/modules/user/userRepository.ts +64 -0
  239. package/src/modules/user/userService.ts +68 -0
  240. package/src/types/extend-express.d.ts +16 -0
  241. package/src/types/function.ts +5 -0
  242. package/src/types/utilities.ts +22 -0
  243. package/src/utils.ts +53 -0
  244. package/tsconfig.json +29 -0
  245. package/dist/modules/subscription/subscriptionPlan.d.ts +0 -4
  246. package/dist/modules/subscription/subscriptionPlan.d.ts.map +0 -1
  247. package/dist/modules/subscription/subscriptionPlan.js +0 -67
  248. package/dist/modules/subscription/subscriptionPlan.js.map +0 -1
@@ -0,0 +1,975 @@
1
+ import { describe, it, beforeEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import crypto from 'crypto';
4
+ import { WebhookHandler } from './webhook.handler';
5
+ import { WebhookHandlerService } from './webhook.service';
6
+ import type { WayForPayCallbackData, IWayForPayService, WayForPayWebhookResponse } from './wayforpay.types';
7
+ import type { Logger } from '../logger/types';
8
+ import type { IPaymentService, PaymentEntity } from './types';
9
+ import type { IInvoiceService, InvoiceEntity } from '../invoice/types';
10
+ import type { ISubscriptionService, SubscriptionEntity } from '../subscription/types';
11
+ import type { ISubscriptionPlanService, SubscriptionPlanEntity } from '../subscriptionPlan/types';
12
+ import type { ITokenPackService, TokenPackEntity } from '../tokenPack/types';
13
+ import type { GracePeriodConfig } from '../../config';
14
+ import type { TokenPackPlan } from '../token/types';
15
+ import { createSubscriptionOrderReference, createTokenPackOrderReference } from './utils';
16
+
17
+ const MERCHANT_SECRET_KEY = 'test-secret-key';
18
+ const MERCHANT_ACCOUNT = 'test_merchant';
19
+
20
+ // Mock logger
21
+ const createMockLogger = (): Logger => ({
22
+ info: mock.fn(),
23
+ warning: mock.fn(),
24
+ error: mock.fn(),
25
+ debug: mock.fn(),
26
+ });
27
+
28
+ // Mock response object
29
+ const createMockResponse = () => {
30
+ let statusCode: number | undefined;
31
+ let jsonData: unknown;
32
+
33
+ const res = {
34
+ status: mock.fn((code: number) => {
35
+ statusCode = code;
36
+ return res;
37
+ }),
38
+ json: mock.fn((data: unknown) => {
39
+ jsonData = data;
40
+ }),
41
+ getStatusCode: () => statusCode,
42
+ getJsonData: () => jsonData,
43
+ };
44
+
45
+ return res;
46
+ };
47
+
48
+ // Calculate signature for callback data
49
+ const calculateSignature = (data: Partial<WayForPayCallbackData>, secretKey: string): string => {
50
+ const signatureData = [
51
+ data.merchantAccount,
52
+ data.orderReference,
53
+ data.amount,
54
+ data.currency,
55
+ data.authCode,
56
+ data.cardPan,
57
+ data.transactionStatus,
58
+ data.reasonCode,
59
+ ];
60
+ const signatureString = signatureData.join(';');
61
+ return crypto.createHmac('md5', secretKey).update(signatureString).digest('hex');
62
+ };
63
+
64
+ // Mock callback data factory
65
+ const createMockCallbackData = (
66
+ overrides: Partial<WayForPayCallbackData> = {},
67
+ secretKey: string = MERCHANT_SECRET_KEY,
68
+ ): WayForPayCallbackData => {
69
+ const baseData: Omit<WayForPayCallbackData, 'merchantSignature'> = {
70
+ merchantAccount: MERCHANT_ACCOUNT,
71
+ orderReference: 'miia_telegram_bot_user123_sub_plan-monthly_v2_20240101120000',
72
+ amount: 100,
73
+ currency: 'UAH',
74
+ authCode: 'auth-123',
75
+ cardPan: '4111****1111',
76
+ transactionStatus: 'Approved',
77
+ reasonCode: '1100',
78
+ email: 'test@example.com',
79
+ phone: '+380123456789',
80
+ createdDate: Math.floor(Date.now() / 1000),
81
+ processingDate: Math.floor(Date.now() / 1000),
82
+ cardType: 'VISA',
83
+ issuerBankCountry: 'UA',
84
+ issuerBankName: 'Test Bank',
85
+ recToken: 'rec-token-123',
86
+ reason: 'Ok',
87
+ fee: 2.5,
88
+ paymentSystem: 'VISA',
89
+ ...overrides,
90
+ };
91
+
92
+ const merchantSignature =
93
+ overrides.merchantSignature !== undefined
94
+ ? overrides.merchantSignature
95
+ : calculateSignature(baseData as Partial<WayForPayCallbackData>, secretKey);
96
+
97
+ return {
98
+ ...baseData,
99
+ merchantSignature,
100
+ } as WayForPayCallbackData;
101
+ };
102
+
103
+ // Mock WayForPay service
104
+ const createMockWayForPayService = (): IWayForPayService => ({
105
+ generateSubscriptionPaymentUrl: mock.fn(async () => null),
106
+ generateOneTimePaymentUrl: mock.fn(async () => null),
107
+ verifyCallbackSignature: mock.fn((data: WayForPayCallbackData) => {
108
+ const expectedSignature = calculateSignature(data, MERCHANT_SECRET_KEY);
109
+ const isValid = data.merchantSignature?.toLowerCase() === expectedSignature.toLowerCase();
110
+ return {
111
+ isValid,
112
+ error: isValid ? undefined : data.merchantSignature ? undefined : 'Missing merchant signature in callback data',
113
+ };
114
+ }),
115
+ buildWebhookResponse: mock.fn((orderReference: string, status: 'accept' | 'decline'): WayForPayWebhookResponse => {
116
+ const time = Math.floor(Date.now() / 1000);
117
+ const signatureData = [orderReference, status, time];
118
+ const signatureString = signatureData.join(';');
119
+ const signature = crypto.createHmac('md5', MERCHANT_SECRET_KEY).update(signatureString).digest('hex');
120
+ return { orderReference, status, time, signature };
121
+ }),
122
+ });
123
+
124
+ // Mock payment service
125
+ const createMockPaymentService = (payment: PaymentEntity | null = null): IPaymentService => ({
126
+ create: mock.fn(async () => ({ id: 'payment-1', status: 'pending' } as PaymentEntity)),
127
+ getByOrderReference: mock.fn(async () => payment),
128
+ getByUser: mock.fn(async () => (payment ? [payment] : [])),
129
+ updateStatus: mock.fn(async () => {}),
130
+ changeStatus: mock.fn(async () => {}),
131
+ getExpiredPendingPayments: mock.fn(async () => []),
132
+ createPaymentIntent: mock.fn(async () => ({ id: 'payment-1' } as PaymentEntity)),
133
+ createTokenPackPaymentIntent: mock.fn(async () => ({ id: 'payment-1' } as PaymentEntity)),
134
+ createUpgradePaymentIntent: mock.fn(async () => ({ id: 'payment-1' } as PaymentEntity)),
135
+ validateSignature: mock.fn(() => ({ isValid: true })),
136
+ });
137
+
138
+ // Mock invoice service
139
+ const createMockInvoiceService = (existingInvoice: InvoiceEntity | null = null): IInvoiceService => ({
140
+ create: mock.fn(async (data) => ({
141
+ id: 'invoice-1',
142
+ ...data,
143
+ } as InvoiceEntity)),
144
+ getByOrderReference: mock.fn(async () => existingInvoice),
145
+ });
146
+
147
+ // Mock subscription service
148
+ const createMockSubscriptionService = (
149
+ existingSubscription: SubscriptionEntity | null = null,
150
+ ): ISubscriptionService => ({
151
+ activateSubscription: mock.fn(async () => {}),
152
+ getByUser: mock.fn(async () => existingSubscription),
153
+ create: mock.fn(async () => ({ id: 'sub-1', status: 'active' } as SubscriptionEntity)),
154
+ updateFieldsByUserId: mock.fn(async () => {}),
155
+ getExpiredActiveSubscriptions: mock.fn(async () => []),
156
+ updateFieldsById: mock.fn(async () => {}),
157
+ getOrCreateSubscriptionPaymentUrl: mock.fn(async () => 'https://payment.url'),
158
+ getSubscriptionsForDeactivationCheck: mock.fn(async () => []),
159
+ renewSubscription: mock.fn(async () => {}),
160
+ cancelSubscription: mock.fn(async () => {}),
161
+ deactivateSubscription: mock.fn(async () => {}),
162
+ getExpiredSubscriptions: mock.fn(async () => []),
163
+ getFailedRenewals: mock.fn(async () => []),
164
+ deactivate: mock.fn(async () => {}),
165
+ });
166
+
167
+ // Mock subscription plan service
168
+ const createMockSubscriptionPlanService = (
169
+ plan: SubscriptionPlanEntity | null = {
170
+ id: 'plan-monthly',
171
+ titleCode: 'plans.monthly.title',
172
+ descriptionCode: 'plans.monthly.description',
173
+ amount: 100,
174
+ currency: 'UAH',
175
+ features: { messages: 1000, images: 100, voice: 50 },
176
+ regularMode: 'monthly',
177
+ count: 1,
178
+ credits: 1000,
179
+ isActive: true,
180
+ createdAt: new Date().toISOString(),
181
+ updatedAt: new Date().toISOString(),
182
+ },
183
+ ): ISubscriptionPlanService => ({
184
+ getById: mock.fn(async () => plan),
185
+ getAll: mock.fn(async () => (plan ? [plan] : [])),
186
+ create: mock.fn(async () => plan as SubscriptionPlanEntity),
187
+ update: mock.fn(async () => plan as SubscriptionPlanEntity),
188
+ deactivate: mock.fn(async () => {}),
189
+ resetCache: mock.fn(() => {}),
190
+ seedDefaults: mock.fn(async () => {}),
191
+ });
192
+
193
+ // Mock token pack service
194
+ const createMockTokenPackService = (): ITokenPackService => ({
195
+ create: mock.fn(async () => ({
196
+ id: 'token-pack-1',
197
+ userId: 'user123',
198
+ platform: 'telegram',
199
+ packId: 'pack-small',
200
+ tokens: 10000,
201
+ tokensRemaining: 10000,
202
+ status: 'active',
203
+ purchasedAt: new Date().toISOString(),
204
+ provider: 'wayforpay',
205
+ } as TokenPackEntity)),
206
+ getByUser: mock.fn(async () => []),
207
+ getById: mock.fn(async () => null),
208
+ updateFieldsById: mock.fn(async () => {}),
209
+ deductTokens: mock.fn(async () => {}),
210
+ getExpiredActiveTokenPacks: mock.fn(async () => []),
211
+ });
212
+
213
+ // Grace period config
214
+ const createMockGracePeriodConfig = (enabled: boolean = true): GracePeriodConfig => ({
215
+ durationMs: 3 * 24 * 60 * 60 * 1000, // 3 days
216
+ enabled,
217
+ });
218
+
219
+ // Minimal mock service for validation-only tests (avoids Firebase initialization)
220
+ const createMinimalMockWebhookService = () => ({
221
+ processPaymentWebhook: mock.fn(async () => ({
222
+ success: true,
223
+ response: { orderReference: 'test', status: 'accept' as const, time: Date.now(), signature: 'sig' },
224
+ })),
225
+ });
226
+
227
+ describe('WebhookHandler Integration Tests', () => {
228
+ let logger: Logger;
229
+
230
+ beforeEach(() => {
231
+ logger = createMockLogger();
232
+ });
233
+
234
+ describe('Purchase Transaction Webhook - HTTP Handler', () => {
235
+ describe('Request Validation', () => {
236
+ it('should return 400 for non-object request body', async () => {
237
+ const handler = new WebhookHandler({ logger, webhookService: createMinimalMockWebhookService() });
238
+ const res = createMockResponse();
239
+
240
+ await handler.handleRequest({ body: null }, res);
241
+
242
+ assert.strictEqual(res.getStatusCode(), 400);
243
+ const jsonData = res.getJsonData() as { error: string };
244
+ assert.strictEqual(jsonData.error, 'Request body must be a JSON object');
245
+ });
246
+
247
+ it('should return 400 for missing required fields', async () => {
248
+ const handler = new WebhookHandler({ logger, webhookService: createMinimalMockWebhookService() });
249
+ const res = createMockResponse();
250
+
251
+ await handler.handleRequest({ body: { merchantAccount: 'test' } }, res);
252
+
253
+ assert.strictEqual(res.getStatusCode(), 400);
254
+ const jsonData = res.getJsonData() as { error: string };
255
+ assert.ok(jsonData.error.includes('Missing required fields'));
256
+ });
257
+
258
+ it('should return 400 for invalid transaction status', async () => {
259
+ const handler = new WebhookHandler({ logger, webhookService: createMinimalMockWebhookService() });
260
+ const res = createMockResponse();
261
+
262
+ await handler.handleRequest({
263
+ body: {
264
+ merchantAccount: 'test',
265
+ orderReference: 'order-123',
266
+ merchantSignature: 'sig',
267
+ amount: 100,
268
+ currency: 'UAH',
269
+ transactionStatus: 'InvalidStatus',
270
+ },
271
+ }, res);
272
+
273
+ assert.strictEqual(res.getStatusCode(), 400);
274
+ const jsonData = res.getJsonData() as { error: string };
275
+ assert.ok(jsonData.error.includes('Invalid transaction status'));
276
+ });
277
+
278
+ it('should accept valid request body with all required fields', async () => {
279
+ const wayForPayService = createMockWayForPayService();
280
+ const webhookService = new WebhookHandlerService({
281
+ logger,
282
+ wayForPayService,
283
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
284
+ invoiceService: createMockInvoiceService(),
285
+ subscriptionService: createMockSubscriptionService(),
286
+ subscriptionPlanService: createMockSubscriptionPlanService(),
287
+ tokenPackService: createMockTokenPackService(),
288
+ gracePeriodConfig: createMockGracePeriodConfig(),
289
+ });
290
+
291
+ const handler = new WebhookHandler({ logger, webhookService });
292
+ const res = createMockResponse();
293
+ const callbackData = createMockCallbackData();
294
+
295
+ await handler.handleRequest({ body: callbackData }, res);
296
+
297
+ assert.strictEqual(res.getStatusCode(), 200);
298
+ });
299
+ });
300
+
301
+ describe('Signature Validation', () => {
302
+ it('should decline webhook with invalid signature', async () => {
303
+ const wayForPayService = createMockWayForPayService();
304
+ const webhookService = new WebhookHandlerService({
305
+ logger,
306
+ wayForPayService,
307
+ paymentService: createMockPaymentService(),
308
+ invoiceService: createMockInvoiceService(),
309
+ subscriptionService: createMockSubscriptionService(),
310
+ subscriptionPlanService: createMockSubscriptionPlanService(),
311
+ tokenPackService: createMockTokenPackService(),
312
+ gracePeriodConfig: createMockGracePeriodConfig(),
313
+ });
314
+
315
+ const handler = new WebhookHandler({ logger, webhookService });
316
+ const res = createMockResponse();
317
+ const callbackData = createMockCallbackData({ merchantSignature: 'invalid-signature' });
318
+
319
+ await handler.handleRequest({ body: callbackData }, res);
320
+
321
+ assert.strictEqual(res.getStatusCode(), 200);
322
+ const jsonData = res.getJsonData() as WayForPayWebhookResponse;
323
+ assert.strictEqual(jsonData.status, 'decline');
324
+ });
325
+
326
+ it('should decline webhook with missing signature', async () => {
327
+ const wayForPayService = createMockWayForPayService();
328
+ const webhookService = new WebhookHandlerService({
329
+ logger,
330
+ wayForPayService,
331
+ paymentService: createMockPaymentService(),
332
+ invoiceService: createMockInvoiceService(),
333
+ subscriptionService: createMockSubscriptionService(),
334
+ subscriptionPlanService: createMockSubscriptionPlanService(),
335
+ tokenPackService: createMockTokenPackService(),
336
+ gracePeriodConfig: createMockGracePeriodConfig(),
337
+ });
338
+
339
+ const handler = new WebhookHandler({ logger, webhookService });
340
+ const res = createMockResponse();
341
+ const callbackData = createMockCallbackData({ merchantSignature: '' });
342
+
343
+ await handler.handleRequest({ body: callbackData }, res);
344
+
345
+ assert.strictEqual(res.getStatusCode(), 200);
346
+ const jsonData = res.getJsonData() as WayForPayWebhookResponse;
347
+ assert.strictEqual(jsonData.status, 'decline');
348
+ });
349
+
350
+ it('should accept webhook with valid signature (case-insensitive)', async () => {
351
+ const wayForPayService = createMockWayForPayService();
352
+ const webhookService = new WebhookHandlerService({
353
+ logger,
354
+ wayForPayService,
355
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
356
+ invoiceService: createMockInvoiceService(),
357
+ subscriptionService: createMockSubscriptionService(),
358
+ subscriptionPlanService: createMockSubscriptionPlanService(),
359
+ tokenPackService: createMockTokenPackService(),
360
+ gracePeriodConfig: createMockGracePeriodConfig(),
361
+ });
362
+
363
+ const handler = new WebhookHandler({ logger, webhookService });
364
+ const res = createMockResponse();
365
+ const callbackData = createMockCallbackData();
366
+ // Make signature uppercase
367
+ callbackData.merchantSignature = callbackData.merchantSignature.toUpperCase();
368
+
369
+ await handler.handleRequest({ body: callbackData }, res);
370
+
371
+ assert.strictEqual(res.getStatusCode(), 200);
372
+ const jsonData = res.getJsonData() as WayForPayWebhookResponse;
373
+ assert.strictEqual(jsonData.status, 'accept');
374
+ });
375
+ });
376
+ });
377
+
378
+ describe('Purchase Transaction Webhook - Service Processing', () => {
379
+ describe('Approved Subscription Payment', () => {
380
+ it('should create invoice for approved subscription payment', async () => {
381
+ const invoiceService = createMockInvoiceService();
382
+ const webhookService = new WebhookHandlerService({
383
+ logger,
384
+ wayForPayService: createMockWayForPayService(),
385
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
386
+ invoiceService,
387
+ subscriptionService: createMockSubscriptionService(),
388
+ subscriptionPlanService: createMockSubscriptionPlanService(),
389
+ tokenPackService: createMockTokenPackService(),
390
+ gracePeriodConfig: createMockGracePeriodConfig(),
391
+ });
392
+
393
+ const orderReference = createSubscriptionOrderReference({
394
+ userId: 'user123',
395
+ platform: 'telegram',
396
+ planId: 'plan-monthly',
397
+ utcDate: '20240101120000',
398
+ });
399
+
400
+ const callbackData = createMockCallbackData({
401
+ orderReference,
402
+ transactionStatus: 'Approved',
403
+ });
404
+
405
+ const result = await webhookService.processPaymentWebhook(callbackData);
406
+
407
+ assert.strictEqual(result.success, true);
408
+ assert.strictEqual(result.response.status, 'accept');
409
+ assert.strictEqual((invoiceService.create as any).mock.calls.length, 1);
410
+ });
411
+
412
+ it('should activate new subscription when none exists', async () => {
413
+ const subscriptionService = createMockSubscriptionService(null);
414
+ const webhookService = new WebhookHandlerService({
415
+ logger,
416
+ wayForPayService: createMockWayForPayService(),
417
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
418
+ invoiceService: createMockInvoiceService(),
419
+ subscriptionService,
420
+ subscriptionPlanService: createMockSubscriptionPlanService(),
421
+ tokenPackService: createMockTokenPackService(),
422
+ gracePeriodConfig: createMockGracePeriodConfig(),
423
+ });
424
+
425
+ const orderReference = createSubscriptionOrderReference({
426
+ userId: 'user123',
427
+ platform: 'telegram',
428
+ planId: 'plan-monthly',
429
+ utcDate: '20240101120000',
430
+ });
431
+
432
+ const callbackData = createMockCallbackData({
433
+ orderReference,
434
+ transactionStatus: 'Approved',
435
+ });
436
+
437
+ const result = await webhookService.processPaymentWebhook(callbackData);
438
+
439
+ assert.strictEqual(result.success, true);
440
+ assert.strictEqual((subscriptionService.activateSubscription as any).mock.calls.length, 1);
441
+ });
442
+
443
+ it('should renew existing subscription', async () => {
444
+ const existingSubscription: SubscriptionEntity = {
445
+ id: 'sub-1',
446
+ userId: 'user123',
447
+ platform: 'telegram',
448
+ planId: 'plan-monthly',
449
+ status: 'active',
450
+ startedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
451
+ expiresAt: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(),
452
+ provider: 'wayforpay',
453
+ };
454
+
455
+ const subscriptionService = createMockSubscriptionService(existingSubscription);
456
+ const webhookService = new WebhookHandlerService({
457
+ logger,
458
+ wayForPayService: createMockWayForPayService(),
459
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
460
+ invoiceService: createMockInvoiceService(),
461
+ subscriptionService,
462
+ subscriptionPlanService: createMockSubscriptionPlanService(),
463
+ tokenPackService: createMockTokenPackService(),
464
+ gracePeriodConfig: createMockGracePeriodConfig(),
465
+ });
466
+
467
+ const orderReference = createSubscriptionOrderReference({
468
+ userId: 'user123',
469
+ platform: 'telegram',
470
+ planId: 'plan-monthly',
471
+ utcDate: '20240101120000',
472
+ });
473
+
474
+ const callbackData = createMockCallbackData({
475
+ orderReference,
476
+ transactionStatus: 'Approved',
477
+ });
478
+
479
+ const result = await webhookService.processPaymentWebhook(callbackData);
480
+
481
+ assert.strictEqual(result.success, true);
482
+ assert.strictEqual((subscriptionService.renewSubscription as any).mock.calls.length, 1);
483
+ assert.strictEqual((subscriptionService.activateSubscription as any).mock.calls.length, 0);
484
+ });
485
+
486
+ it('should update payment status to completed', async () => {
487
+ const paymentService = createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity);
488
+ const webhookService = new WebhookHandlerService({
489
+ logger,
490
+ wayForPayService: createMockWayForPayService(),
491
+ paymentService,
492
+ invoiceService: createMockInvoiceService(),
493
+ subscriptionService: createMockSubscriptionService(),
494
+ subscriptionPlanService: createMockSubscriptionPlanService(),
495
+ tokenPackService: createMockTokenPackService(),
496
+ gracePeriodConfig: createMockGracePeriodConfig(),
497
+ });
498
+
499
+ const orderReference = createSubscriptionOrderReference({
500
+ userId: 'user123',
501
+ platform: 'telegram',
502
+ planId: 'plan-monthly',
503
+ utcDate: '20240101120000',
504
+ });
505
+
506
+ const callbackData = createMockCallbackData({
507
+ orderReference,
508
+ transactionStatus: 'Approved',
509
+ });
510
+
511
+ await webhookService.processPaymentWebhook(callbackData);
512
+
513
+ assert.strictEqual((paymentService.changeStatus as any).mock.calls.length, 1);
514
+ const changeStatusCall = (paymentService.changeStatus as any).mock.calls[0];
515
+ assert.strictEqual(changeStatusCall.arguments[0].status, 'completed');
516
+ });
517
+
518
+ it('should initialize token balance based on subscription plan', async () => {
519
+ const tokenPackService = createMockTokenPackService();
520
+ const webhookService = new WebhookHandlerService({
521
+ logger,
522
+ wayForPayService: createMockWayForPayService(),
523
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
524
+ invoiceService: createMockInvoiceService(),
525
+ subscriptionService: createMockSubscriptionService(),
526
+ subscriptionPlanService: createMockSubscriptionPlanService({
527
+ id: 'plan-monthly',
528
+ titleCode: 'plans.monthly.title',
529
+ descriptionCode: 'plans.monthly.description',
530
+ amount: 100,
531
+ currency: 'UAH',
532
+ features: { messages: 1000, images: 100, voice: 50 },
533
+ regularMode: 'monthly',
534
+ count: 1,
535
+ credits: 5000,
536
+ isActive: true,
537
+ createdAt: new Date().toISOString(),
538
+ updatedAt: new Date().toISOString(),
539
+ }),
540
+ tokenPackService,
541
+ gracePeriodConfig: createMockGracePeriodConfig(),
542
+ });
543
+
544
+ const orderReference = createSubscriptionOrderReference({
545
+ userId: 'user123',
546
+ platform: 'telegram',
547
+ planId: 'plan-monthly',
548
+ utcDate: '20240101120000',
549
+ });
550
+
551
+ const callbackData = createMockCallbackData({
552
+ orderReference,
553
+ transactionStatus: 'Approved',
554
+ });
555
+
556
+ await webhookService.processPaymentWebhook(callbackData);
557
+
558
+ assert.strictEqual((tokenPackService.create as any).mock.calls.length, 1);
559
+ const createCall = (tokenPackService.create as any).mock.calls[0];
560
+ assert.strictEqual(createCall.arguments[0].tokens, 5000);
561
+ });
562
+
563
+ it('should handle duplicate webhook (idempotency)', async () => {
564
+ const existingInvoice: InvoiceEntity = {
565
+ id: 'invoice-1',
566
+ merchantAccount: MERCHANT_ACCOUNT,
567
+ orderReference: 'miia_telegram_bot_user123_sub_plan-monthly_v2_20240101120000',
568
+ merchantSignature: 'sig',
569
+ reasonCode: 1100,
570
+ reason: 'Ok',
571
+ createdDate: new Date().toISOString(),
572
+ processingDate: new Date().toISOString(),
573
+ currency: 'UAH',
574
+ amount: 100,
575
+ };
576
+
577
+ const invoiceService = createMockInvoiceService(existingInvoice);
578
+ const subscriptionService = createMockSubscriptionService();
579
+ const webhookService = new WebhookHandlerService({
580
+ logger,
581
+ wayForPayService: createMockWayForPayService(),
582
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
583
+ invoiceService,
584
+ subscriptionService,
585
+ subscriptionPlanService: createMockSubscriptionPlanService(),
586
+ tokenPackService: createMockTokenPackService(),
587
+ gracePeriodConfig: createMockGracePeriodConfig(),
588
+ });
589
+
590
+ const callbackData = createMockCallbackData({
591
+ orderReference: existingInvoice.orderReference,
592
+ transactionStatus: 'Approved',
593
+ });
594
+
595
+ const result = await webhookService.processPaymentWebhook(callbackData);
596
+
597
+ assert.strictEqual(result.success, true);
598
+ // Invoice should not be created again
599
+ assert.strictEqual((invoiceService.create as any).mock.calls.length, 0);
600
+ // Subscription should not be activated again
601
+ assert.strictEqual((subscriptionService.activateSubscription as any).mock.calls.length, 0);
602
+ });
603
+ });
604
+
605
+ describe('Approved Token Pack Payment', () => {
606
+ it('should create invoice for approved token pack payment', async () => {
607
+ const invoiceService = createMockInvoiceService();
608
+ const tokenPackPlans: TokenPackPlan[] = [{
609
+ id: 'pack-small',
610
+ titleCode: 'packs.small.title',
611
+ descriptionCode: 'packs.small.description',
612
+ tokens: 10000,
613
+ amount: 49,
614
+ currency: 'UAH',
615
+ isActive: true,
616
+ }];
617
+
618
+ const webhookService = new WebhookHandlerService({
619
+ logger,
620
+ wayForPayService: createMockWayForPayService(),
621
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
622
+ invoiceService,
623
+ subscriptionService: createMockSubscriptionService(),
624
+ subscriptionPlanService: createMockSubscriptionPlanService(),
625
+ tokenPackService: createMockTokenPackService(),
626
+ tokenPackPlans,
627
+ gracePeriodConfig: createMockGracePeriodConfig(),
628
+ });
629
+
630
+ const orderReference = createTokenPackOrderReference({
631
+ userId: 'user123',
632
+ platform: 'telegram',
633
+ packId: 'pack-small',
634
+ utcDate: '20240101120000',
635
+ });
636
+
637
+ const callbackData = createMockCallbackData({
638
+ orderReference,
639
+ transactionStatus: 'Approved',
640
+ });
641
+
642
+ const result = await webhookService.processPaymentWebhook(callbackData);
643
+
644
+ assert.strictEqual(result.success, true);
645
+ assert.strictEqual(result.response.status, 'accept');
646
+ assert.strictEqual((invoiceService.create as any).mock.calls.length, 1);
647
+ });
648
+
649
+ it('should create token pack for user', async () => {
650
+ const tokenPackService = createMockTokenPackService();
651
+ const tokenPackPlans: TokenPackPlan[] = [{
652
+ id: 'pack-small',
653
+ titleCode: 'packs.small.title',
654
+ descriptionCode: 'packs.small.description',
655
+ tokens: 10000,
656
+ amount: 49,
657
+ currency: 'UAH',
658
+ isActive: true,
659
+ }];
660
+
661
+ const webhookService = new WebhookHandlerService({
662
+ logger,
663
+ wayForPayService: createMockWayForPayService(),
664
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
665
+ invoiceService: createMockInvoiceService(),
666
+ subscriptionService: createMockSubscriptionService(),
667
+ subscriptionPlanService: createMockSubscriptionPlanService(),
668
+ tokenPackService,
669
+ tokenPackPlans,
670
+ gracePeriodConfig: createMockGracePeriodConfig(),
671
+ });
672
+
673
+ const orderReference = createTokenPackOrderReference({
674
+ userId: 'user123',
675
+ platform: 'telegram',
676
+ packId: 'pack-small',
677
+ utcDate: '20240101120000',
678
+ });
679
+
680
+ const callbackData = createMockCallbackData({
681
+ orderReference,
682
+ transactionStatus: 'Approved',
683
+ });
684
+
685
+ await webhookService.processPaymentWebhook(callbackData);
686
+
687
+ assert.strictEqual((tokenPackService.create as any).mock.calls.length, 1);
688
+ const createCall = (tokenPackService.create as any).mock.calls[0];
689
+ assert.strictEqual(createCall.arguments[0].tokens, 10000);
690
+ assert.strictEqual(createCall.arguments[0].userId, 'user123');
691
+ assert.strictEqual(createCall.arguments[0].packId, 'pack-small');
692
+ });
693
+ });
694
+
695
+ describe('Declined/Failed Payment', () => {
696
+ it('should accept webhook for declined transaction without processing', async () => {
697
+ const invoiceService = createMockInvoiceService();
698
+ const subscriptionService = createMockSubscriptionService();
699
+ const webhookService = new WebhookHandlerService({
700
+ logger,
701
+ wayForPayService: createMockWayForPayService(),
702
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
703
+ invoiceService,
704
+ subscriptionService,
705
+ subscriptionPlanService: createMockSubscriptionPlanService(),
706
+ tokenPackService: createMockTokenPackService(),
707
+ gracePeriodConfig: createMockGracePeriodConfig(),
708
+ });
709
+
710
+ const orderReference = createSubscriptionOrderReference({
711
+ userId: 'user123',
712
+ platform: 'telegram',
713
+ planId: 'plan-monthly',
714
+ utcDate: '20240101120000',
715
+ });
716
+
717
+ const callbackData = createMockCallbackData({
718
+ orderReference,
719
+ transactionStatus: 'Declined',
720
+ });
721
+
722
+ const result = await webhookService.processPaymentWebhook(callbackData);
723
+
724
+ assert.strictEqual(result.success, true);
725
+ assert.strictEqual(result.response.status, 'accept');
726
+ // No invoice should be created for declined transaction
727
+ assert.strictEqual((invoiceService.create as any).mock.calls.length, 0);
728
+ });
729
+
730
+ it('should update payment status to failed for declined transaction', async () => {
731
+ const paymentService = createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity);
732
+ const webhookService = new WebhookHandlerService({
733
+ logger,
734
+ wayForPayService: createMockWayForPayService(),
735
+ paymentService,
736
+ invoiceService: createMockInvoiceService(),
737
+ subscriptionService: createMockSubscriptionService(),
738
+ subscriptionPlanService: createMockSubscriptionPlanService(),
739
+ tokenPackService: createMockTokenPackService(),
740
+ gracePeriodConfig: createMockGracePeriodConfig(),
741
+ });
742
+
743
+ const orderReference = createSubscriptionOrderReference({
744
+ userId: 'user123',
745
+ platform: 'telegram',
746
+ planId: 'plan-monthly',
747
+ utcDate: '20240101120000',
748
+ });
749
+
750
+ const callbackData = createMockCallbackData({
751
+ orderReference,
752
+ transactionStatus: 'Declined',
753
+ });
754
+
755
+ await webhookService.processPaymentWebhook(callbackData);
756
+
757
+ assert.strictEqual((paymentService.changeStatus as any).mock.calls.length, 1);
758
+ const changeStatusCall = (paymentService.changeStatus as any).mock.calls[0];
759
+ assert.strictEqual(changeStatusCall.arguments[0].status, 'failed');
760
+ });
761
+
762
+ it('should accept webhook for expired transaction', async () => {
763
+ const webhookService = new WebhookHandlerService({
764
+ logger,
765
+ wayForPayService: createMockWayForPayService(),
766
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
767
+ invoiceService: createMockInvoiceService(),
768
+ subscriptionService: createMockSubscriptionService(),
769
+ subscriptionPlanService: createMockSubscriptionPlanService(),
770
+ tokenPackService: createMockTokenPackService(),
771
+ gracePeriodConfig: createMockGracePeriodConfig(),
772
+ });
773
+
774
+ const orderReference = createSubscriptionOrderReference({
775
+ userId: 'user123',
776
+ platform: 'telegram',
777
+ planId: 'plan-monthly',
778
+ utcDate: '20240101120000',
779
+ });
780
+
781
+ const callbackData = createMockCallbackData({
782
+ orderReference,
783
+ transactionStatus: 'Expired',
784
+ });
785
+
786
+ const result = await webhookService.processPaymentWebhook(callbackData);
787
+
788
+ assert.strictEqual(result.success, true);
789
+ assert.strictEqual(result.response.status, 'accept');
790
+ });
791
+
792
+ it('should handle failed recurring renewal - deactivate subscription past grace period', async () => {
793
+ const pastExpiration = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
794
+ const existingSubscription: SubscriptionEntity = {
795
+ id: 'sub-1',
796
+ userId: 'user123',
797
+ platform: 'telegram',
798
+ planId: 'plan-monthly',
799
+ status: 'active',
800
+ startedAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(),
801
+ expiresAt: pastExpiration.toISOString(),
802
+ provider: 'wayforpay',
803
+ };
804
+
805
+ const subscriptionService = createMockSubscriptionService(existingSubscription);
806
+ const webhookService = new WebhookHandlerService({
807
+ logger,
808
+ wayForPayService: createMockWayForPayService(),
809
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
810
+ invoiceService: createMockInvoiceService(),
811
+ subscriptionService,
812
+ subscriptionPlanService: createMockSubscriptionPlanService(),
813
+ tokenPackService: createMockTokenPackService(),
814
+ gracePeriodConfig: createMockGracePeriodConfig(true),
815
+ });
816
+
817
+ const orderReference = createSubscriptionOrderReference({
818
+ userId: 'user123',
819
+ platform: 'telegram',
820
+ planId: 'plan-monthly',
821
+ utcDate: '20240101120000',
822
+ });
823
+
824
+ const callbackData = createMockCallbackData({
825
+ orderReference,
826
+ transactionStatus: 'Declined',
827
+ regularOn: '1', // Indicates recurring payment
828
+ });
829
+
830
+ await webhookService.processPaymentWebhook(callbackData);
831
+
832
+ // Should deactivate since past grace period
833
+ assert.strictEqual((subscriptionService.deactivateSubscription as any).mock.calls.length, 1);
834
+ });
835
+
836
+ it('should handle failed recurring renewal - cancel subscription within grace period', async () => {
837
+ const recentExpiration = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago (within 3-day grace)
838
+ const existingSubscription: SubscriptionEntity = {
839
+ id: 'sub-1',
840
+ userId: 'user123',
841
+ platform: 'telegram',
842
+ planId: 'plan-monthly',
843
+ status: 'active',
844
+ startedAt: new Date(Date.now() - 32 * 24 * 60 * 60 * 1000).toISOString(),
845
+ expiresAt: recentExpiration.toISOString(),
846
+ provider: 'wayforpay',
847
+ };
848
+
849
+ const subscriptionService = createMockSubscriptionService(existingSubscription);
850
+ const webhookService = new WebhookHandlerService({
851
+ logger,
852
+ wayForPayService: createMockWayForPayService(),
853
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
854
+ invoiceService: createMockInvoiceService(),
855
+ subscriptionService,
856
+ subscriptionPlanService: createMockSubscriptionPlanService(),
857
+ tokenPackService: createMockTokenPackService(),
858
+ gracePeriodConfig: createMockGracePeriodConfig(true),
859
+ });
860
+
861
+ const orderReference = createSubscriptionOrderReference({
862
+ userId: 'user123',
863
+ platform: 'telegram',
864
+ planId: 'plan-monthly',
865
+ utcDate: '20240101120000',
866
+ });
867
+
868
+ const callbackData = createMockCallbackData({
869
+ orderReference,
870
+ transactionStatus: 'Declined',
871
+ regularOn: '1',
872
+ });
873
+
874
+ await webhookService.processPaymentWebhook(callbackData);
875
+
876
+ // Should cancel (not deactivate) since within grace period
877
+ assert.strictEqual((subscriptionService.cancelSubscription as any).mock.calls.length, 1);
878
+ assert.strictEqual((subscriptionService.deactivateSubscription as any).mock.calls.length, 0);
879
+ });
880
+ });
881
+
882
+ describe('Error Handling', () => {
883
+ it('should return decline response on processing error', async () => {
884
+ const subscriptionPlanService = createMockSubscriptionPlanService(null);
885
+ const webhookService = new WebhookHandlerService({
886
+ logger,
887
+ wayForPayService: createMockWayForPayService(),
888
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
889
+ invoiceService: createMockInvoiceService(),
890
+ subscriptionService: createMockSubscriptionService(),
891
+ subscriptionPlanService,
892
+ tokenPackService: createMockTokenPackService(),
893
+ gracePeriodConfig: createMockGracePeriodConfig(),
894
+ });
895
+
896
+ const orderReference = createSubscriptionOrderReference({
897
+ userId: 'user123',
898
+ platform: 'telegram',
899
+ planId: 'non-existent-plan',
900
+ utcDate: '20240101120000',
901
+ });
902
+
903
+ const callbackData = createMockCallbackData({
904
+ orderReference,
905
+ transactionStatus: 'Approved',
906
+ });
907
+
908
+ const result = await webhookService.processPaymentWebhook(callbackData);
909
+
910
+ assert.strictEqual(result.success, false);
911
+ assert.strictEqual(result.response.status, 'decline');
912
+ assert.ok(result.error);
913
+ });
914
+
915
+ it('should handle invalid order reference format', async () => {
916
+ const webhookService = new WebhookHandlerService({
917
+ logger,
918
+ wayForPayService: createMockWayForPayService(),
919
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
920
+ invoiceService: createMockInvoiceService(),
921
+ subscriptionService: createMockSubscriptionService(),
922
+ subscriptionPlanService: createMockSubscriptionPlanService(),
923
+ tokenPackService: createMockTokenPackService(),
924
+ gracePeriodConfig: createMockGracePeriodConfig(),
925
+ });
926
+
927
+ const callbackData = createMockCallbackData({
928
+ orderReference: 'invalid-format',
929
+ transactionStatus: 'Approved',
930
+ });
931
+
932
+ const result = await webhookService.processPaymentWebhook(callbackData);
933
+
934
+ // Unknown payment type should just accept
935
+ assert.strictEqual(result.success, true);
936
+ assert.strictEqual(result.response.status, 'accept');
937
+ });
938
+ });
939
+ });
940
+
941
+ describe('Direct Callback Processing', () => {
942
+ it('should process callback directly via processCallback method', async () => {
943
+ const wayForPayService = createMockWayForPayService();
944
+ const webhookService = new WebhookHandlerService({
945
+ logger,
946
+ wayForPayService,
947
+ paymentService: createMockPaymentService({ id: 'pay-1', status: 'pending' } as PaymentEntity),
948
+ invoiceService: createMockInvoiceService(),
949
+ subscriptionService: createMockSubscriptionService(),
950
+ subscriptionPlanService: createMockSubscriptionPlanService(),
951
+ tokenPackService: createMockTokenPackService(),
952
+ gracePeriodConfig: createMockGracePeriodConfig(),
953
+ });
954
+
955
+ const handler = new WebhookHandler({ logger, webhookService });
956
+
957
+ const orderReference = createSubscriptionOrderReference({
958
+ userId: 'user123',
959
+ platform: 'telegram',
960
+ planId: 'plan-monthly',
961
+ utcDate: '20240101120000',
962
+ });
963
+
964
+ const callbackData = createMockCallbackData({
965
+ orderReference,
966
+ transactionStatus: 'Approved',
967
+ });
968
+
969
+ const result = await handler.processCallback(callbackData);
970
+
971
+ assert.strictEqual(result.success, true);
972
+ assert.strictEqual(result.response.status, 'accept');
973
+ });
974
+ });
975
+ });