payment-kit 1.24.4 → 1.25.1

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 (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,109 @@
1
+ import {
2
+ buildSlippageSnapshot,
3
+ DEFAULT_SLIPPAGE_PERCENT,
4
+ isRateBelowMinAcceptableRate,
5
+ normalizeSlippageConfigFromMetadata,
6
+ resolveSlippagePercent,
7
+ } from '../../src/libs/slippage';
8
+
9
+ describe('libs/slippage', () => {
10
+ describe('normalizeSlippageConfigFromMetadata', () => {
11
+ it('returns null when no slippage metadata is present', () => {
12
+ expect(normalizeSlippageConfigFromMetadata(null)).toBeNull();
13
+ expect(normalizeSlippageConfigFromMetadata(undefined)).toBeNull();
14
+ expect(normalizeSlippageConfigFromMetadata({})).toBeNull();
15
+ });
16
+
17
+ it('normalizes percent mode with fallback percent', () => {
18
+ const result = normalizeSlippageConfigFromMetadata({
19
+ slippage: {
20
+ mode: 'percent',
21
+ percent: 'invalid',
22
+ },
23
+ });
24
+
25
+ expect(result).not.toBeNull();
26
+ expect(result?.mode).toBe('percent');
27
+ expect(result?.percent).toBe(DEFAULT_SLIPPAGE_PERCENT);
28
+ });
29
+
30
+ it('preserves rate mode config fields', () => {
31
+ const result = normalizeSlippageConfigFromMetadata({
32
+ slippage: {
33
+ mode: 'rate',
34
+ percent: '1.25',
35
+ min_acceptable_rate: '0.9',
36
+ base_currency: 'USD',
37
+ updated_at_ms: 123,
38
+ },
39
+ });
40
+
41
+ expect(result).toEqual({
42
+ mode: 'rate',
43
+ percent: 1.25,
44
+ min_acceptable_rate: '0.9',
45
+ base_currency: 'USD',
46
+ updated_at_ms: 123,
47
+ });
48
+ });
49
+ });
50
+
51
+ describe('resolveSlippagePercent', () => {
52
+ it('prefers quote slippage percent over session metadata', () => {
53
+ const quote = { slippage_percent: 2 };
54
+ const session = { metadata: { slippage: { percent: 0.5 } }, slippage_percent: 1 };
55
+
56
+ expect(resolveSlippagePercent(quote, session)).toBe(2);
57
+ });
58
+
59
+ it('falls back to session metadata when quote is missing', () => {
60
+ const quote = { metadata: {} };
61
+ const session = { metadata: { slippage: { percent: 1.1 } } };
62
+
63
+ expect(resolveSlippagePercent(quote, session)).toBe(1.1);
64
+ });
65
+
66
+ it('falls back to default when values are invalid', () => {
67
+ const quote = { slippage_percent: -1 };
68
+
69
+ expect(resolveSlippagePercent(quote, undefined)).toBe(DEFAULT_SLIPPAGE_PERCENT);
70
+ });
71
+ });
72
+
73
+ describe('buildSlippageSnapshot', () => {
74
+ it('derives max_payable_token and min_acceptable_rate from percent', () => {
75
+ const snapshot = buildSlippageSnapshot({
76
+ quote: {
77
+ quoted_amount: '1000',
78
+ exchange_rate: '100',
79
+ slippage_percent: 25,
80
+ },
81
+ checkoutSession: { metadata: {} },
82
+ nowMs: 456,
83
+ });
84
+
85
+ expect(snapshot).toEqual({
86
+ percent: 25,
87
+ max_payable_token: '1250',
88
+ min_acceptable_rate: '80',
89
+ derived_at_ms: 456,
90
+ });
91
+ });
92
+ });
93
+
94
+ describe('isRateBelowMinAcceptableRate', () => {
95
+ it('returns true when current rate is below min acceptable rate', () => {
96
+ expect(isRateBelowMinAcceptableRate('0.9', '1')).toBe(true);
97
+ });
98
+
99
+ it('returns false when current rate is above or equal to min acceptable rate', () => {
100
+ expect(isRateBelowMinAcceptableRate('1', '1')).toBe(false);
101
+ expect(isRateBelowMinAcceptableRate('1.1', '1')).toBe(false);
102
+ });
103
+
104
+ it('returns false for invalid inputs', () => {
105
+ expect(isRateBelowMinAcceptableRate('abc', '1')).toBe(false);
106
+ expect(isRateBelowMinAcceptableRate('1', 'xyz')).toBe(false);
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,267 @@
1
+ import axios from 'axios';
2
+ import { TokenDataProvider } from '../../src/libs/exchange-rate/token-data-provider';
3
+
4
+ jest.mock('axios');
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>;
6
+
7
+ // Mock the token-address-mapping module
8
+ jest.mock('../../src/libs/exchange-rate/token-address-mapping', () => ({
9
+ getTokenInfo: jest.fn((symbol: string) => {
10
+ const symbolUpper = symbol.toUpperCase();
11
+ const mapping: Record<string, any> = {
12
+ ABT: {
13
+ symbol: 'ABT',
14
+ address: '0xB98d4C97425d9908E66E53A6fDf673ACcA0BE986',
15
+ name: 'ArcBlock Token',
16
+ decimals: 18,
17
+ },
18
+ ETH: {
19
+ symbol: 'ETH',
20
+ address: '0x0000000000000000000000000000000000000000',
21
+ name: 'Ethereum',
22
+ decimals: 18,
23
+ },
24
+ };
25
+ return mapping[symbolUpper];
26
+ }),
27
+ }));
28
+
29
+ describe('TokenDataProvider', () => {
30
+ let provider: TokenDataProvider;
31
+
32
+ beforeEach(() => {
33
+ provider = new TokenDataProvider();
34
+ jest.clearAllMocks();
35
+ });
36
+
37
+ describe('fetch', () => {
38
+ it('should fetch exchange rate successfully', async () => {
39
+ const mockResponse = {
40
+ data: {
41
+ data: [
42
+ {
43
+ symbol: 'ABT',
44
+ name: 'ArcBlock Token',
45
+ price: {
46
+ USD: 0.15,
47
+ },
48
+ updatedAt: '2024-01-01T00:00:00Z',
49
+ },
50
+ ],
51
+ total: 1,
52
+ page: 1,
53
+ size: 10,
54
+ },
55
+ };
56
+
57
+ mockedAxios.get.mockResolvedValue(mockResponse);
58
+
59
+ const result = await provider.fetch('ABT');
60
+
61
+ expect(result.rate).toBe('0.15');
62
+ expect(result.timestamp_ms).toBeGreaterThan(0);
63
+ expect(result.metadata?.source).toBe('token-data');
64
+ expect(mockedAxios.get).toHaveBeenCalledWith(
65
+ 'https://token-data.arcblock.io/api/token-price-by-address',
66
+ expect.objectContaining({
67
+ params: {
68
+ addresses: '0xb98d4c97425d9908e66e53a6fdf673acca0be986',
69
+ },
70
+ timeout: 10000,
71
+ })
72
+ );
73
+ });
74
+
75
+ it('should handle alternative response format with symbol endpoint', async () => {
76
+ // ETH has zero address, so it should use symbol endpoint
77
+ const mockResponse = {
78
+ data: {
79
+ data: [
80
+ {
81
+ symbol: 'ETH',
82
+ name: 'Ethereum',
83
+ price: {
84
+ USD: 2000.5,
85
+ },
86
+ updatedAt: '2024-01-01T00:00:00Z',
87
+ },
88
+ ],
89
+ total: 1,
90
+ page: 1,
91
+ size: 10,
92
+ },
93
+ };
94
+
95
+ mockedAxios.get.mockResolvedValue(mockResponse);
96
+
97
+ const result = await provider.fetch('ETH');
98
+
99
+ expect(result.rate).toBe('2000.5');
100
+ expect(result.timestamp_ms).toBeGreaterThan(0);
101
+ expect(mockedAxios.get).toHaveBeenCalledWith(
102
+ 'https://token-data.arcblock.io/api/token-price-by-symbol',
103
+ expect.objectContaining({
104
+ params: {
105
+ symbols: 'ETH',
106
+ },
107
+ })
108
+ );
109
+ });
110
+
111
+ it('should throw error for invalid price', async () => {
112
+ const mockResponse = {
113
+ data: {
114
+ data: [
115
+ {
116
+ symbol: 'ABT',
117
+ price: {
118
+ USD: -1,
119
+ },
120
+ },
121
+ ],
122
+ },
123
+ };
124
+
125
+ mockedAxios.get.mockResolvedValue(mockResponse);
126
+
127
+ await expect(provider.fetch('ABT')).rejects.toThrow('Invalid price value');
128
+ });
129
+
130
+ it('should throw error for missing USD price', async () => {
131
+ const mockResponse = {
132
+ data: {
133
+ data: [
134
+ {
135
+ symbol: 'ABT',
136
+ price: {},
137
+ },
138
+ ],
139
+ },
140
+ };
141
+
142
+ mockedAxios.get.mockResolvedValue(mockResponse);
143
+
144
+ await expect(provider.fetch('ABT')).rejects.toThrow('No valid USD price found');
145
+ });
146
+
147
+ it('should throw error for zero price', async () => {
148
+ const mockResponse = {
149
+ data: {
150
+ data: [
151
+ {
152
+ symbol: 'ABT',
153
+ price: {
154
+ USD: 0,
155
+ },
156
+ },
157
+ ],
158
+ },
159
+ };
160
+
161
+ mockedAxios.get.mockResolvedValue(mockResponse);
162
+
163
+ // Zero is falsy, so it gets caught by the !usdPrice check first
164
+ await expect(provider.fetch('ABT')).rejects.toThrow('No valid USD price found');
165
+ });
166
+
167
+ it('should throw error for empty data array', async () => {
168
+ const mockResponse = {
169
+ data: {
170
+ data: [],
171
+ total: 0,
172
+ },
173
+ };
174
+
175
+ mockedAxios.get.mockResolvedValue(mockResponse);
176
+
177
+ await expect(provider.fetch('ABT')).rejects.toThrow('No token data found for ABT');
178
+ });
179
+
180
+ it('should throw SymbolNotSupportedError for unknown token', async () => {
181
+ // Re-mock getTokenInfo to return undefined for UNKNOWN
182
+ const { getTokenInfo } = jest.requireMock('../../src/libs/exchange-rate/token-address-mapping');
183
+ getTokenInfo.mockReturnValueOnce(undefined);
184
+
185
+ await expect(provider.fetch('UNKNOWN')).rejects.toThrow('Symbol UNKNOWN is not supported by token-data');
186
+ });
187
+
188
+ it('should handle 500 server error', async () => {
189
+ mockedAxios.get.mockRejectedValue({
190
+ response: { status: 500 },
191
+ });
192
+
193
+ await expect(provider.fetch('ABT')).rejects.toThrow('token-data server error: 500');
194
+ });
195
+
196
+ it('should handle timeout error', async () => {
197
+ mockedAxios.get.mockRejectedValue({
198
+ code: 'ECONNABORTED',
199
+ message: 'timeout',
200
+ });
201
+
202
+ await expect(provider.fetch('ABT')).rejects.toThrow('token-data request timeout after 10000ms');
203
+ });
204
+
205
+ it('should handle connection error', async () => {
206
+ mockedAxios.get.mockRejectedValue({
207
+ code: 'ECONNREFUSED',
208
+ message: 'Connection refused',
209
+ });
210
+
211
+ await expect(provider.fetch('ABT')).rejects.toThrow('Cannot connect to token-data');
212
+ });
213
+
214
+ it('should use custom base URL from config', async () => {
215
+ const customProvider = new TokenDataProvider({
216
+ base_url: 'https://custom.example.com',
217
+ });
218
+
219
+ const mockResponse = {
220
+ data: {
221
+ data: [
222
+ {
223
+ symbol: 'ABT',
224
+ price: {
225
+ USD: 0.15,
226
+ },
227
+ },
228
+ ],
229
+ },
230
+ };
231
+
232
+ mockedAxios.get.mockResolvedValue(mockResponse);
233
+
234
+ await customProvider.fetch('ABT');
235
+
236
+ expect(mockedAxios.get).toHaveBeenCalledWith(
237
+ 'https://custom.example.com/api/token-price-by-address',
238
+ expect.anything()
239
+ );
240
+ });
241
+
242
+ it('should convert symbol to uppercase in token lookup', async () => {
243
+ const mockResponse = {
244
+ data: {
245
+ data: [
246
+ {
247
+ symbol: 'ABT',
248
+ price: {
249
+ USD: 0.15,
250
+ },
251
+ },
252
+ ],
253
+ },
254
+ };
255
+
256
+ mockedAxios.get.mockResolvedValue(mockResponse);
257
+
258
+ await provider.fetch('abt');
259
+
260
+ // The getTokenInfo mock is called with 'abt', but it handles uppercase internally
261
+ expect(mockedAxios.get).toHaveBeenCalledWith(
262
+ expect.stringContaining('token-price-by-address'),
263
+ expect.anything()
264
+ );
265
+ });
266
+ });
267
+ });
@@ -0,0 +1,121 @@
1
+ import { ExchangeRateProvider } from '../../src/store/models/exchange-rate-provider';
2
+
3
+ describe('ExchangeRateProvider Model', () => {
4
+ describe('recordSuccess logic', () => {
5
+ it('should reset failure count and update status to ok', () => {
6
+ // Test the logic for recordSuccess
7
+ const updates = {
8
+ last_success_at: expect.any(Date),
9
+ failure_count: 0,
10
+ status: 'ok',
11
+ };
12
+
13
+ expect(updates.failure_count).toBe(0);
14
+ expect(updates.status).toBe('ok');
15
+ });
16
+ });
17
+
18
+ describe('recordFailure logic', () => {
19
+ it('should increment failure count and set status to degraded', () => {
20
+ const currentFailureCount = 0;
21
+ const newFailureCount = currentFailureCount + 1;
22
+
23
+ const updates: any = {
24
+ last_failure_at: new Date(),
25
+ failure_count: newFailureCount,
26
+ };
27
+
28
+ // Auto-pause after 5 consecutive failures
29
+ if (newFailureCount >= 5) {
30
+ updates.status = 'paused';
31
+ updates.paused_reason = 'Test error';
32
+ } else {
33
+ updates.status = 'degraded';
34
+ }
35
+
36
+ expect(updates.failure_count).toBe(1);
37
+ expect(updates.status).toBe('degraded');
38
+ });
39
+
40
+ it('should set status to paused after 5 consecutive failures', () => {
41
+ const currentFailureCount = 4;
42
+ const newFailureCount = currentFailureCount + 1;
43
+ const reason = 'Test error';
44
+
45
+ const updates: any = {
46
+ last_failure_at: new Date(),
47
+ failure_count: newFailureCount,
48
+ };
49
+
50
+ if (newFailureCount >= 5) {
51
+ updates.status = 'paused';
52
+ updates.paused_reason = reason || 'Too many consecutive failures';
53
+ } else {
54
+ updates.status = 'degraded';
55
+ }
56
+
57
+ expect(updates.failure_count).toBe(5);
58
+ expect(updates.status).toBe('paused');
59
+ expect(updates.paused_reason).toBe('Test error');
60
+ });
61
+
62
+ it('should use default reason if none provided', () => {
63
+ const currentFailureCount = 4;
64
+ const newFailureCount = currentFailureCount + 1;
65
+ const reason = undefined;
66
+
67
+ const updates: any = {
68
+ last_failure_at: new Date(),
69
+ failure_count: newFailureCount,
70
+ };
71
+
72
+ if (newFailureCount >= 5) {
73
+ updates.status = 'paused';
74
+ updates.paused_reason = reason || 'Too many consecutive failures';
75
+ } else {
76
+ updates.status = 'degraded';
77
+ }
78
+
79
+ expect(updates.paused_reason).toBe('Too many consecutive failures');
80
+ });
81
+
82
+ it('should not pause before reaching 5 failures', () => {
83
+ const currentFailureCount = 3;
84
+ const newFailureCount = currentFailureCount + 1;
85
+
86
+ const updates: any = {
87
+ last_failure_at: new Date(),
88
+ failure_count: newFailureCount,
89
+ };
90
+
91
+ if (newFailureCount >= 5) {
92
+ updates.status = 'paused';
93
+ updates.paused_reason = 'Test error';
94
+ } else {
95
+ updates.status = 'degraded';
96
+ }
97
+
98
+ expect(updates.failure_count).toBe(4);
99
+ expect(updates.status).toBe('degraded');
100
+ });
101
+ });
102
+
103
+ describe('Status values', () => {
104
+ it('should have valid status enum values', () => {
105
+ const validStatuses = ['ok', 'degraded', 'paused', 'down'];
106
+
107
+ validStatuses.forEach((status) => {
108
+ expect(validStatuses).toContain(status);
109
+ });
110
+ });
111
+ });
112
+
113
+ describe('Provider priority', () => {
114
+ it('should support priority ordering', () => {
115
+ const provider1 = { priority: 1 } as ExchangeRateProvider;
116
+ const provider2 = { priority: 2 } as ExchangeRateProvider;
117
+
118
+ expect(provider1.priority).toBeLessThan(provider2.priority);
119
+ });
120
+ });
121
+ });
@@ -0,0 +1,100 @@
1
+ import { Price } from '../../src/store/models/price';
2
+
3
+ describe('Price Model - Dynamic Pricing', () => {
4
+ describe('formatBeforeSave - Dynamic Pricing Validation', () => {
5
+ it('should validate dynamic pricing requires base_currency and base_amount', () => {
6
+ const price = {
7
+ pricing_type: 'dynamic' as const,
8
+ // Missing base_currency and base_amount
9
+ };
10
+
11
+ expect(() => Price.formatBeforeSave(price)).toThrow(
12
+ 'base_currency and base_amount are required for dynamic pricing'
13
+ );
14
+ });
15
+
16
+ it('should validate only USD is supported as base_currency', () => {
17
+ const price = {
18
+ pricing_type: 'dynamic' as const,
19
+ base_currency: 'EUR',
20
+ base_amount: '100',
21
+ };
22
+
23
+ expect(() => Price.formatBeforeSave(price)).toThrow('Only USD is supported as base_currency');
24
+ });
25
+
26
+ it('should accept valid dynamic pricing with USD', () => {
27
+ const price = {
28
+ pricing_type: 'dynamic' as const,
29
+ base_currency: 'USD',
30
+ base_amount: '100',
31
+ };
32
+
33
+ const result = Price.formatBeforeSave(price);
34
+
35
+ expect(result.pricing_type).toBe('dynamic');
36
+ expect(result.base_currency).toBe('USD');
37
+ expect(result.base_amount).toBe('100');
38
+ });
39
+
40
+ it('should set default lock_duration if not provided', () => {
41
+ const price = {
42
+ pricing_type: 'dynamic' as const,
43
+ base_currency: 'USD',
44
+ base_amount: '100',
45
+ };
46
+
47
+ const result = Price.formatBeforeSave(price);
48
+
49
+ expect(result.dynamic_pricing_config).toEqual({ lock_duration: 30 });
50
+ });
51
+
52
+ it('should preserve custom lock_duration if provided', () => {
53
+ const price = {
54
+ pricing_type: 'dynamic' as const,
55
+ base_currency: 'USD',
56
+ base_amount: '100',
57
+ dynamic_pricing_config: { lock_duration: 600 },
58
+ };
59
+
60
+ const result = Price.formatBeforeSave(price);
61
+
62
+ expect(result.dynamic_pricing_config).toEqual({ lock_duration: 600 });
63
+ });
64
+
65
+ it('should clear dynamic pricing fields for fixed prices', () => {
66
+ const price = {
67
+ pricing_type: 'fixed' as const,
68
+ base_currency: 'USD',
69
+ base_amount: '100',
70
+ dynamic_pricing_config: { lock_duration: 300 },
71
+ };
72
+
73
+ const result = Price.formatBeforeSave(price);
74
+
75
+ expect(result.pricing_type).toBe('fixed');
76
+ expect(result.base_currency).toBeNull();
77
+ expect(result.base_amount).toBeNull();
78
+ expect(result.dynamic_pricing_config).toBeNull();
79
+ });
80
+
81
+ it('should not modify other price properties', () => {
82
+ const price = {
83
+ pricing_type: 'dynamic' as const,
84
+ base_currency: 'USD',
85
+ base_amount: '100',
86
+ type: 'one_time' as const,
87
+ billing_scheme: 'per_unit' as const,
88
+ unit_amount: '500',
89
+ metadata: { key: 'value' },
90
+ };
91
+
92
+ const result = Price.formatBeforeSave(price);
93
+
94
+ expect(result.type).toBe('one_time');
95
+ expect(result.billing_scheme).toBe('per_unit');
96
+ expect(result.unit_amount).toBe('500');
97
+ expect(result.metadata).toEqual({ key: 'value' });
98
+ });
99
+ });
100
+ });
@@ -0,0 +1,112 @@
1
+ // import { PriceQuote } from '../../src/store/models/price-quote';
2
+
3
+ describe('PriceQuote Model', () => {
4
+ describe('isExpired logic', () => {
5
+ it('should return true if quote is expired', () => {
6
+ const now = Math.floor(Date.now() / 1000);
7
+ const expiresAt = now - 100; // Expired 100 seconds ago
8
+
9
+ // Test the logic directly
10
+ const isExpired = expiresAt < Math.floor(Date.now() / 1000);
11
+ expect(isExpired).toBe(true);
12
+ });
13
+
14
+ it('should return false if quote is not expired', () => {
15
+ const now = Math.floor(Date.now() / 1000);
16
+ const expiresAt = now + 100; // Expires in 100 seconds
17
+
18
+ // Test the logic directly
19
+ const isExpired = expiresAt < Math.floor(Date.now() / 1000);
20
+ expect(isExpired).toBe(false);
21
+ });
22
+
23
+ it('should handle edge case at expiration time', () => {
24
+ const now = Math.floor(Date.now() / 1000);
25
+ const expiresAt = now;
26
+
27
+ // At expiration time, should be considered expired
28
+ const isExpired = expiresAt < Math.floor(Date.now() / 1000);
29
+ expect(typeof isExpired).toBe('boolean');
30
+ });
31
+ });
32
+
33
+ describe('isActive logic', () => {
34
+ it('should return true if status is active and not expired', () => {
35
+ const now = Math.floor(Date.now() / 1000);
36
+ const status = 'active';
37
+ const expiresAt = now + 100;
38
+
39
+ const isActive = status === 'active' && expiresAt >= Math.floor(Date.now() / 1000);
40
+ expect(isActive).toBe(true);
41
+ });
42
+
43
+ it('should return false if status is not active', () => {
44
+ const now = Math.floor(Date.now() / 1000);
45
+ const status: string = 'used';
46
+ const expiresAt = now + 100;
47
+
48
+ const isActive = status === 'active' && expiresAt >= Math.floor(Date.now() / 1000);
49
+ expect(isActive).toBe(false);
50
+ });
51
+
52
+ it('should return false if quote is expired even if status is active', () => {
53
+ const now = Math.floor(Date.now() / 1000);
54
+ const status = 'active';
55
+ const expiresAt = now - 100;
56
+
57
+ const isActive = status === 'active' && expiresAt >= Math.floor(Date.now() / 1000);
58
+ expect(isActive).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe('Quote Status Transitions', () => {
63
+ it('should have valid quote status values', () => {
64
+ const validStatuses = ['active', 'used', 'paid', 'expired', 'cancelled', 'failed'];
65
+
66
+ validStatuses.forEach((status) => {
67
+ expect(validStatuses).toContain(status);
68
+ });
69
+ });
70
+ });
71
+
72
+ describe('Quote Metadata Structure', () => {
73
+ it('should have correct metadata structure', () => {
74
+ const metadata = {
75
+ calculation: {
76
+ token_amount_raw: '100.123456789',
77
+ unit_amount_raw: '100123456789000000000',
78
+ },
79
+ rounding: {
80
+ mode: 'ceil',
81
+ token_decimals: 18,
82
+ },
83
+ risk: {
84
+ anomaly_detected: false,
85
+ deviation_percent: 2.3,
86
+ degraded: false,
87
+ degraded_reason: null,
88
+ },
89
+ context: {
90
+ quantity: 1,
91
+ base_amount_per_unit: '10',
92
+ },
93
+ };
94
+
95
+ expect(metadata.calculation).toBeDefined();
96
+ expect(metadata.rounding).toBeDefined();
97
+ expect(metadata.risk).toBeDefined();
98
+ expect(metadata.context).toBeDefined();
99
+ });
100
+ });
101
+
102
+ describe('Quote Expiration Calculation', () => {
103
+ it('should correctly calculate expiration timestamp', () => {
104
+ const now = Math.floor(Date.now() / 1000);
105
+ const lockDuration = 300; // 5 minutes
106
+ const expiresAt = now + lockDuration;
107
+
108
+ expect(expiresAt).toBeGreaterThan(now);
109
+ expect(expiresAt - now).toBe(lockDuration);
110
+ });
111
+ });
112
+ });