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,248 @@
1
+ import { Router } from 'express';
2
+ import pick from 'lodash/pick';
3
+ import { Op } from 'sequelize';
4
+
5
+ import { authenticate } from '../libs/security';
6
+ import { ExchangeRateProvider } from '../store/models/exchange-rate-provider';
7
+ import { TokenDataProvider } from '../libs/exchange-rate/token-data-provider';
8
+ import { CoinGeckoProvider } from '../libs/exchange-rate/coingecko-provider';
9
+ import { CoinMarketCapProvider } from '../libs/exchange-rate/coinmarketcap-provider';
10
+
11
+ const router = Router();
12
+ const auth = authenticate({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
13
+ const allowedStatuses = ['active', 'degraded', 'paused', 'inactive'];
14
+
15
+ router.get('/', auth, async (_req, res) => {
16
+ const providers = await ExchangeRateProvider.findAll({
17
+ order: [
18
+ ['priority', 'ASC'],
19
+ ['created_at', 'ASC'],
20
+ ],
21
+ });
22
+
23
+ // Mask sensitive config data (API keys) before sending to client
24
+ const maskedProviders = providers.map((p) => ({
25
+ ...p.toJSON(),
26
+ config: ExchangeRateProvider.maskConfig(p.config),
27
+ }));
28
+
29
+ res.json({ data: maskedProviders });
30
+ });
31
+
32
+ router.post('/', auth, async (req, res) => {
33
+ const {
34
+ name,
35
+ type = 'token-data',
36
+ enabled = true,
37
+ priority = 1,
38
+ status = 'active',
39
+ config = {},
40
+ // eslint-disable-next-line @typescript-eslint/naming-convention
41
+ paused_reason = null,
42
+ } = req.body;
43
+ if (!name) {
44
+ res.status(400).json({ error: 'name is required' });
45
+ return;
46
+ }
47
+ if (Number.isNaN(Number(priority))) {
48
+ res.status(400).json({ error: 'priority must be a number' });
49
+ return;
50
+ }
51
+ if (!allowedStatuses.includes(status)) {
52
+ res.status(400).json({ error: 'invalid status' });
53
+ return;
54
+ }
55
+
56
+ const provider = await ExchangeRateProvider.create({
57
+ name,
58
+ type,
59
+ enabled: !!enabled,
60
+ priority: Number(priority),
61
+ status,
62
+ paused_reason: status === 'paused' ? paused_reason : null,
63
+ config: ExchangeRateProvider.encryptConfig(config),
64
+ });
65
+
66
+ res.json({ data: provider });
67
+ });
68
+
69
+ router.put('/:id', auth, async (req, res) => {
70
+ const provider = await ExchangeRateProvider.findByPk(req.params.id);
71
+ if (!provider) {
72
+ res.status(404).json({ error: 'Provider not found' });
73
+ return;
74
+ }
75
+
76
+ // Note: 'type' is intentionally excluded - cannot be changed after creation
77
+ const updates = pick(req.body, ['name', 'enabled', 'priority', 'status', 'paused_reason', 'config']);
78
+ if (updates.priority !== undefined && Number.isNaN(Number(updates.priority))) {
79
+ res.status(400).json({ error: 'priority must be a number' });
80
+ return;
81
+ }
82
+ if (updates.status && !allowedStatuses.includes(updates.status)) {
83
+ res.status(400).json({ error: 'invalid status' });
84
+ return;
85
+ }
86
+
87
+ // Encrypt config if it's being updated
88
+ if (updates.config) {
89
+ const existingConfig = ExchangeRateProvider.decryptConfig(provider.config) || {};
90
+ const mergedConfig = { ...existingConfig, ...updates.config };
91
+ if (updates.config.api_key === '') {
92
+ delete mergedConfig.api_key;
93
+ }
94
+ updates.config = ExchangeRateProvider.encryptConfig(mergedConfig);
95
+ }
96
+
97
+ await provider.update(updates);
98
+ res.json({ data: provider });
99
+ });
100
+
101
+ // TECHNICAL DEBT: Exchange rate provider management bypasses AFS layer
102
+ // TODO: Migrate to AFS-based provider management when AFS layer is available
103
+ // Context: This CRUD API was built before AFS implementation
104
+ // Impact: UI is tightly coupled to backend schema, not replayable via AFS paths
105
+ // Migration Path: Implement $afs:/admin/providers/*.json views
106
+ // Decision: Accepted as pragmatic completion of existing system (2026-01-12)
107
+ // Reference: ai/intent/20260112-token-data-provider-management-alignment.md
108
+
109
+ /**
110
+ * DELETE endpoint - Remove an exchange rate provider
111
+ * Safety constraints:
112
+ * 1. Cannot delete provider that is currently in use
113
+ * 2. Cannot delete the last enabled provider
114
+ */
115
+ router.delete('/:id', auth, async (req, res) => {
116
+ const provider = await ExchangeRateProvider.findByPk(req.params.id);
117
+ if (!provider) {
118
+ res.status(404).json({ error: 'Provider not found' });
119
+ return;
120
+ }
121
+
122
+ // Safety Check 1: Get active provider (same logic as frontend)
123
+ const allProviders = await ExchangeRateProvider.findAll({
124
+ order: [
125
+ ['priority', 'ASC'],
126
+ ['created_at', 'ASC'],
127
+ ],
128
+ });
129
+
130
+ const activeProviders = allProviders
131
+ .filter((p) => p.enabled && p.status !== 'paused')
132
+ .sort((a, b) => a.priority - b.priority);
133
+
134
+ const activeProviderId = activeProviders[0]?.id || null;
135
+
136
+ if (provider.id === activeProviderId) {
137
+ res.status(400).json({
138
+ error: 'Cannot delete provider that is currently in use',
139
+ });
140
+ return;
141
+ }
142
+
143
+ // Safety Check 2: Ensure we're not deleting the last enabled provider
144
+ const otherEnabledCount = await ExchangeRateProvider.count({
145
+ where: {
146
+ id: { [Op.ne]: req.params.id },
147
+ enabled: true,
148
+ status: { [Op.ne]: 'paused' },
149
+ },
150
+ });
151
+
152
+ if (otherEnabledCount === 0 && provider.enabled && provider.status !== 'paused') {
153
+ res.status(400).json({
154
+ error:
155
+ 'Cannot delete the last enabled provider. Please ensure at least one other provider is enabled before deleting this one.',
156
+ });
157
+ return;
158
+ }
159
+
160
+ // All checks passed, safe to delete
161
+ await provider.destroy();
162
+
163
+ res.json({ success: true });
164
+ });
165
+
166
+ /**
167
+ * POST /test-connection - Test provider connection without saving
168
+ * Validates provider configuration by attempting to fetch a test rate
169
+ */
170
+ router.post('/test-connection', auth, async (req, res) => {
171
+ const { type, config = {}, provider_id: providerId } = req.body;
172
+
173
+ // Validate provider type
174
+ const allowedTypes = ['token-data', 'coingecko', 'coinmarketcap'];
175
+ if (!type || !allowedTypes.includes(type)) {
176
+ res.status(400).json({ error: 'Invalid provider type' });
177
+ return;
178
+ }
179
+
180
+ // Decrypt config if it contains encrypted api_key
181
+ const decryptedConfig = ExchangeRateProvider.decryptConfig(config) || {};
182
+ let effectiveConfig = decryptedConfig;
183
+ if (providerId) {
184
+ const provider = await ExchangeRateProvider.findByPk(providerId);
185
+ if (provider) {
186
+ const existingConfig = ExchangeRateProvider.decryptConfig(provider.config) || {};
187
+ if (decryptedConfig.api_key === '') {
188
+ delete existingConfig.api_key;
189
+ }
190
+ const mergedConfig = {
191
+ ...existingConfig,
192
+ ...decryptedConfig,
193
+ };
194
+ effectiveConfig = mergedConfig;
195
+ }
196
+ }
197
+
198
+ // Create provider instance based on type
199
+ let provider;
200
+ try {
201
+ switch (type) {
202
+ case 'token-data':
203
+ provider = new TokenDataProvider(effectiveConfig || undefined);
204
+ break;
205
+ case 'coingecko':
206
+ provider = new CoinGeckoProvider(effectiveConfig || undefined);
207
+ break;
208
+ case 'coinmarketcap':
209
+ provider = new CoinMarketCapProvider(effectiveConfig || undefined);
210
+ break;
211
+ default:
212
+ res.status(400).json({ error: 'Invalid provider type' });
213
+ return;
214
+ }
215
+ } catch (error: any) {
216
+ res.status(200).json({
217
+ success: false,
218
+ error: error.message || 'Failed to initialize provider',
219
+ });
220
+ return;
221
+ }
222
+
223
+ const testSymbol = type === 'token-data' ? 'ABT' : 'ETH';
224
+ const startTime = Date.now();
225
+
226
+ try {
227
+ const result = await provider.fetch(testSymbol);
228
+ const responseTime = Date.now() - startTime;
229
+
230
+ res.json({
231
+ success: true,
232
+ responseTime,
233
+ rate: result.rate,
234
+ timestamp: result.timestamp_ms,
235
+ symbol: testSymbol,
236
+ });
237
+ } catch (error: any) {
238
+ const responseTime = Date.now() - startTime;
239
+ res.status(200).json({
240
+ success: false,
241
+ responseTime,
242
+ error: error.message || 'Connection test failed',
243
+ symbol: testSymbol,
244
+ });
245
+ }
246
+ });
247
+
248
+ export default router;
@@ -0,0 +1,87 @@
1
+ import { Router } from 'express';
2
+
3
+ import { getExchangeRateService } from '../libs/exchange-rate/service';
4
+ import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
5
+ import { authenticate } from '../libs/security';
6
+ import { ChainType, PaymentCurrency, PaymentMethod } from '../store/models';
7
+
8
+ const router = Router();
9
+ const auth = authenticate({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
10
+ const exchangeRateService = getExchangeRateService();
11
+
12
+ /**
13
+ * Validate if exchange rate can be fetched for a currency
14
+ * This is used during price creation/editing to validate the base currency
15
+ */
16
+ router.post('/validate', auth, async (req, res) => {
17
+ try {
18
+ const { currency: currencyId } = req.body;
19
+
20
+ if (!currencyId) {
21
+ return res.status(400).json({ error: 'currency is required' });
22
+ }
23
+
24
+ const paymentCurrency = (await PaymentCurrency.findByPk(currencyId, {
25
+ include: [
26
+ {
27
+ model: PaymentMethod,
28
+ as: 'payment_method',
29
+ },
30
+ ],
31
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
32
+
33
+ if (!paymentCurrency) {
34
+ return res.status(400).json({ error: 'Currency not found' });
35
+ }
36
+
37
+ if (paymentCurrency.payment_method?.type === 'stripe') {
38
+ return res.status(400).json({
39
+ error: `Currency ${paymentCurrency.symbol} is not supported.`,
40
+ supported: false,
41
+ });
42
+ }
43
+
44
+ // Check if token has address mapping
45
+ if (!hasTokenAddress(paymentCurrency.symbol, paymentCurrency.payment_method?.type as ChainType)) {
46
+ return res.status(400).json({
47
+ error: `Currency ${paymentCurrency.symbol} is not supported.`,
48
+ supported: false,
49
+ });
50
+ }
51
+
52
+ // Try to fetch exchange rate
53
+ try {
54
+ const rateSymbol = getExchangeRateSymbol(
55
+ paymentCurrency.symbol,
56
+ paymentCurrency.payment_method?.type as ChainType
57
+ );
58
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
59
+
60
+ // Return full rate info consistent with checkout-sessions exchange-rate endpoint
61
+ return res.json({
62
+ supported: true,
63
+ currency: paymentCurrency.symbol,
64
+ base_currency: 'USD',
65
+ rate: rateResult.rate,
66
+ timestamp_ms: rateResult.timestamp_ms,
67
+ fetched_at: rateResult.fetched_at,
68
+ provider_id: rateResult.provider_id,
69
+ provider_name: rateResult.provider_name,
70
+ provider_display: rateResult.provider_display,
71
+ providers: rateResult.providers,
72
+ consensus_method: rateResult.consensus_method,
73
+ degraded: rateResult.degraded,
74
+ degraded_reason: rateResult.degraded_reason,
75
+ });
76
+ } catch (error: any) {
77
+ return res.status(400).json({
78
+ error: `Failed to fetch exchange rate for ${paymentCurrency.symbol}: ${error.message}`,
79
+ supported: false,
80
+ });
81
+ }
82
+ } catch (error: any) {
83
+ return res.status(400).json({ error: error.message });
84
+ }
85
+ });
86
+
87
+ export default router;
@@ -28,6 +28,8 @@ import products from './products';
28
28
  import promotionCodes from './promotion-codes';
29
29
  import redirect from './redirect';
30
30
  import refunds from './refunds';
31
+ import exchangeRateProviders from './exchange-rate-providers';
32
+ import exchangeRates from './exchange-rates';
31
33
  import settings from './settings';
32
34
  import subscriptionItems from './subscription-items';
33
35
  import subscriptions from './subscriptions';
@@ -82,6 +84,8 @@ router.use('/pricing-tables', pricingTables);
82
84
  router.use('/tax-rates', taxRates);
83
85
  router.use('/products', products);
84
86
  router.use('/promotion-codes', promotionCodes);
87
+ router.use('/exchange-rate-providers', exchangeRateProviders);
88
+ router.use('/exchange-rates', exchangeRates);
85
89
  router.use('/payouts', payouts);
86
90
  router.use('/redirect', redirect);
87
91
  router.use('/refunds', refunds);