payment-kit 1.24.3 → 1.25.0

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 (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  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/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,245 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import { DataTypes } from 'sequelize';
3
+
4
+ import { Migration, safeApplyColumnChanges, createIndexIfNotExists } from '../migrate';
5
+
6
+ export const up: Migration = async ({ context }) => {
7
+ // Add dynamic pricing fields to prices table
8
+ await safeApplyColumnChanges(context, {
9
+ prices: [
10
+ {
11
+ name: 'pricing_type',
12
+ field: {
13
+ type: DataTypes.ENUM('fixed', 'dynamic'),
14
+ defaultValue: 'fixed',
15
+ allowNull: false,
16
+ },
17
+ },
18
+ {
19
+ name: 'base_currency',
20
+ field: {
21
+ type: DataTypes.STRING(8),
22
+ allowNull: true,
23
+ },
24
+ },
25
+ {
26
+ name: 'base_amount',
27
+ field: {
28
+ type: DataTypes.STRING(32),
29
+ allowNull: true,
30
+ },
31
+ },
32
+ {
33
+ name: 'dynamic_pricing_config',
34
+ field: {
35
+ type: DataTypes.JSON,
36
+ allowNull: true,
37
+ },
38
+ },
39
+ ],
40
+ });
41
+
42
+ // Create exchange_rate_providers table
43
+ const tables = await context.showAllTables();
44
+ if (!tables.includes('exchange_rate_providers')) {
45
+ await context.createTable('exchange_rate_providers', {
46
+ id: {
47
+ type: DataTypes.STRING(32),
48
+ primaryKey: true,
49
+ allowNull: false,
50
+ },
51
+ name: {
52
+ type: DataTypes.STRING(64),
53
+ allowNull: false,
54
+ unique: true,
55
+ },
56
+ type: {
57
+ type: DataTypes.STRING(32),
58
+ allowNull: false,
59
+ defaultValue: 'token-data',
60
+ },
61
+ enabled: {
62
+ type: DataTypes.BOOLEAN,
63
+ allowNull: false,
64
+ defaultValue: true,
65
+ },
66
+ priority: {
67
+ type: DataTypes.INTEGER,
68
+ allowNull: false,
69
+ defaultValue: 1,
70
+ },
71
+ status: {
72
+ type: DataTypes.ENUM('active', 'degraded', 'paused', 'inactive'),
73
+ allowNull: false,
74
+ defaultValue: 'active',
75
+ },
76
+ paused_reason: {
77
+ type: DataTypes.STRING(512),
78
+ allowNull: true,
79
+ },
80
+ config: {
81
+ type: DataTypes.JSON,
82
+ allowNull: true,
83
+ },
84
+ last_success_at: {
85
+ type: DataTypes.DATE,
86
+ allowNull: true,
87
+ },
88
+ last_failure_at: {
89
+ type: DataTypes.DATE,
90
+ allowNull: true,
91
+ },
92
+ failure_count: {
93
+ type: DataTypes.INTEGER,
94
+ allowNull: false,
95
+ defaultValue: 0,
96
+ },
97
+ created_at: {
98
+ type: DataTypes.DATE,
99
+ defaultValue: DataTypes.NOW,
100
+ allowNull: false,
101
+ },
102
+ updated_at: {
103
+ type: DataTypes.DATE,
104
+ defaultValue: DataTypes.NOW,
105
+ allowNull: false,
106
+ },
107
+ });
108
+
109
+ await createIndexIfNotExists(context, 'exchange_rate_providers', ['name'], 'idx_erp_name', {
110
+ unique: true,
111
+ });
112
+ await createIndexIfNotExists(
113
+ context,
114
+ 'exchange_rate_providers',
115
+ ['enabled', 'priority'],
116
+ 'idx_erp_enabled_priority'
117
+ );
118
+
119
+ // Insert default token-data provider
120
+ await context.bulkInsert('exchange_rate_providers', [
121
+ {
122
+ id: `erp_${Date.now()}`,
123
+ name: 'token-data',
124
+ type: 'token-data',
125
+ enabled: true,
126
+ priority: 1,
127
+ status: 'active',
128
+ config: JSON.stringify({ base_url: 'https://token-data.arcblock.io' }),
129
+ failure_count: 0,
130
+ created_at: new Date(),
131
+ updated_at: new Date(),
132
+ },
133
+ ]);
134
+ }
135
+
136
+ // Create price_quotes table
137
+ if (!tables.includes('price_quotes')) {
138
+ await context.createTable('price_quotes', {
139
+ id: {
140
+ type: DataTypes.STRING(32),
141
+ primaryKey: true,
142
+ allowNull: false,
143
+ },
144
+ price_id: {
145
+ type: DataTypes.STRING(32),
146
+ allowNull: false,
147
+ },
148
+ session_id: {
149
+ type: DataTypes.STRING(32),
150
+ allowNull: true,
151
+ },
152
+ invoice_id: {
153
+ type: DataTypes.STRING(32),
154
+ allowNull: true,
155
+ },
156
+ idempotency_key: {
157
+ type: DataTypes.STRING(128),
158
+ allowNull: false,
159
+ unique: true,
160
+ },
161
+ base_currency: {
162
+ type: DataTypes.STRING(8),
163
+ allowNull: false,
164
+ defaultValue: 'USD',
165
+ },
166
+ base_amount: {
167
+ type: DataTypes.STRING(32),
168
+ allowNull: false,
169
+ },
170
+ target_currency_id: {
171
+ type: DataTypes.STRING(16),
172
+ allowNull: false,
173
+ },
174
+ rate_currency_symbol: {
175
+ type: DataTypes.STRING(16),
176
+ allowNull: false,
177
+ },
178
+ exchange_rate: {
179
+ type: DataTypes.STRING(32),
180
+ allowNull: false,
181
+ },
182
+ quoted_amount: {
183
+ type: DataTypes.STRING(64),
184
+ allowNull: false,
185
+ },
186
+ rate_provider_id: {
187
+ type: DataTypes.STRING(32),
188
+ allowNull: false,
189
+ },
190
+ rate_provider_name: {
191
+ type: DataTypes.STRING(64),
192
+ allowNull: false,
193
+ },
194
+ rate_timestamp_ms: {
195
+ type: DataTypes.BIGINT,
196
+ allowNull: false,
197
+ },
198
+ expires_at: {
199
+ type: DataTypes.INTEGER,
200
+ allowNull: false,
201
+ },
202
+ status: {
203
+ type: DataTypes.ENUM('active', 'used', 'paid', 'expired', 'cancelled', 'failed'),
204
+ allowNull: false,
205
+ defaultValue: 'active',
206
+ },
207
+ metadata: {
208
+ type: DataTypes.JSON,
209
+ allowNull: true,
210
+ },
211
+ created_at: {
212
+ type: DataTypes.DATE,
213
+ defaultValue: DataTypes.NOW,
214
+ allowNull: false,
215
+ },
216
+ });
217
+
218
+ await createIndexIfNotExists(context, 'price_quotes', ['idempotency_key'], 'idx_pq_idempotency', {
219
+ unique: true,
220
+ });
221
+ await createIndexIfNotExists(
222
+ context,
223
+ 'price_quotes',
224
+ ['session_id', 'status', 'expires_at'],
225
+ 'idx_pq_session_status_expires'
226
+ );
227
+ await createIndexIfNotExists(context, 'price_quotes', ['invoice_id', 'status'], 'idx_pq_invoice_status');
228
+ await createIndexIfNotExists(
229
+ context,
230
+ 'price_quotes',
231
+ ['rate_currency_symbol', 'created_at'],
232
+ 'idx_pq_currency_created'
233
+ );
234
+ await createIndexIfNotExists(context, 'price_quotes', ['created_at'], 'idx_pq_created');
235
+ }
236
+ };
237
+
238
+ export const down: Migration = async ({ context }) => {
239
+ await context.removeColumn('prices', 'pricing_type');
240
+ await context.removeColumn('prices', 'base_currency');
241
+ await context.removeColumn('prices', 'base_amount');
242
+ await context.removeColumn('prices', 'dynamic_pricing_config');
243
+ await context.dropTable('exchange_rate_providers');
244
+ await context.dropTable('price_quotes');
245
+ };
@@ -0,0 +1,28 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ // Add type field to exchange_rate_providers table
7
+ await safeApplyColumnChanges(context, {
8
+ exchange_rate_providers: [
9
+ {
10
+ name: 'type',
11
+ field: {
12
+ type: DataTypes.STRING(32),
13
+ allowNull: false,
14
+ defaultValue: 'token-data',
15
+ },
16
+ },
17
+ ],
18
+ });
19
+
20
+ // Update existing provider to have type field
21
+ await context.sequelize.query(
22
+ "UPDATE exchange_rate_providers SET type = 'token-data' WHERE type IS NULL OR type = ''"
23
+ );
24
+ };
25
+
26
+ export const down: Migration = async ({ context }) => {
27
+ await context.removeColumn('exchange_rate_providers', 'type');
28
+ };
@@ -0,0 +1,23 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import logger from '../../libs/logger';
4
+ import { Migration } from '../migrate';
5
+
6
+ export const up: Migration = async ({ context }) => {
7
+ const [results] = await context.sequelize.query('PRAGMA table_info(payment_intents)');
8
+ const columnInfo = (results as any[]).find((col: any) => col.name === 'quote_locked_at');
9
+
10
+ if (columnInfo) {
11
+ logger.info('quote_locked_at already exists on payment_intents, skipping migration');
12
+ return;
13
+ }
14
+
15
+ await context.addColumn('payment_intents', 'quote_locked_at', {
16
+ type: DataTypes.DATE,
17
+ allowNull: true,
18
+ });
19
+ };
20
+
21
+ export const down: Migration = async ({ context }) => {
22
+ await context.removeColumn('payment_intents', 'quote_locked_at');
23
+ };
@@ -0,0 +1,22 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ checkout_sessions: [
8
+ {
9
+ name: 'slippage_percent',
10
+ field: {
11
+ type: DataTypes.DECIMAL(5, 2),
12
+ allowNull: false,
13
+ defaultValue: 0.5,
14
+ },
15
+ },
16
+ ],
17
+ });
18
+ };
19
+
20
+ export const down: Migration = async ({ context }) => {
21
+ await context.removeColumn('checkout_sessions', 'slippage_percent');
22
+ };
@@ -0,0 +1,45 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ price_quotes: [
8
+ {
9
+ name: 'slippage_percent',
10
+ field: {
11
+ type: DataTypes.DECIMAL(5, 2),
12
+ allowNull: true,
13
+ },
14
+ },
15
+ {
16
+ name: 'max_payable_token',
17
+ field: {
18
+ type: DataTypes.STRING(64),
19
+ allowNull: true,
20
+ },
21
+ },
22
+ {
23
+ name: 'min_acceptable_rate',
24
+ field: {
25
+ type: DataTypes.STRING(32),
26
+ allowNull: true,
27
+ },
28
+ },
29
+ {
30
+ name: 'slippage_derived_at_ms',
31
+ field: {
32
+ type: DataTypes.BIGINT,
33
+ allowNull: true,
34
+ },
35
+ },
36
+ ],
37
+ });
38
+ };
39
+
40
+ export const down: Migration = async ({ context }) => {
41
+ await context.removeColumn('price_quotes', 'slippage_percent');
42
+ await context.removeColumn('price_quotes', 'max_payable_token');
43
+ await context.removeColumn('price_quotes', 'min_acceptable_rate');
44
+ await context.removeColumn('price_quotes', 'slippage_derived_at_ms');
45
+ };
@@ -0,0 +1,21 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ subscriptions: [
8
+ {
9
+ name: 'slippage_config',
10
+ field: {
11
+ type: DataTypes.JSON,
12
+ allowNull: true,
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ };
18
+
19
+ export const down: Migration = async ({ context }) => {
20
+ await context.removeColumn('subscriptions', 'slippage_config');
21
+ };
@@ -0,0 +1,21 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ auto_recharge_configs: [
8
+ {
9
+ name: 'slippage_config',
10
+ field: {
11
+ type: DataTypes.JSON,
12
+ allowNull: true,
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ };
18
+
19
+ export const down: Migration = async ({ context }) => {
20
+ await context.removeColumn('auto_recharge_configs', 'slippage_config');
21
+ };
@@ -36,6 +36,14 @@ export class AutoRechargeConfig extends Model<InferAttributes<AutoRechargeConfig
36
36
  total_amount: string;
37
37
  };
38
38
 
39
+ declare slippage_config?: {
40
+ mode?: 'percent' | 'rate';
41
+ percent?: number;
42
+ min_acceptable_rate?: string;
43
+ base_currency?: string;
44
+ updated_at_ms?: number;
45
+ } | null;
46
+
39
47
  declare metadata?: Record<string, any>;
40
48
  declare created_at: CreationOptional<Date>;
41
49
  declare updated_at: CreationOptional<Date>;
@@ -121,6 +129,10 @@ export class AutoRechargeConfig extends Model<InferAttributes<AutoRechargeConfig
121
129
  total_amount: '0',
122
130
  },
123
131
  },
132
+ slippage_config: {
133
+ type: DataTypes.JSON,
134
+ allowNull: true,
135
+ },
124
136
  metadata: {
125
137
  type: DataTypes.JSON,
126
138
  allowNull: true,
@@ -83,6 +83,8 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
83
83
  declare amount_subtotal: string;
84
84
  // Total of all items after discounts and taxes are applied.
85
85
  declare amount_total: string;
86
+ // User slippage percent (e.g., 0.5 means 0.5%)
87
+ declare slippage_percent?: number;
86
88
  // Tax and discount details for the computed total amount.
87
89
  declare total_details?: {
88
90
  amount_discount?: string;
@@ -326,6 +328,11 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
326
328
  type: DataTypes.STRING(32),
327
329
  allowNull: false,
328
330
  },
331
+ slippage_percent: {
332
+ type: DataTypes.DECIMAL(5, 2),
333
+ allowNull: false,
334
+ defaultValue: 0.5,
335
+ },
329
336
  total_details: {
330
337
  type: DataTypes.JSON,
331
338
  allowNull: false,
@@ -0,0 +1,225 @@
1
+ import security from '@blocklet/sdk/lib/security';
2
+ import cloneDeep from 'lodash/cloneDeep';
3
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
4
+ import type { LiteralUnion } from 'type-fest';
5
+
6
+ import { createEvent } from '../../libs/audit';
7
+ import { createIdGenerator } from '../../libs/util';
8
+
9
+ export const nextExchangeRateProviderId = createIdGenerator('erp', 24);
10
+
11
+ export class ExchangeRateProvider extends Model<
12
+ InferAttributes<ExchangeRateProvider>,
13
+ InferCreationAttributes<ExchangeRateProvider>
14
+ > {
15
+ declare id: CreationOptional<string>;
16
+ declare name: string;
17
+ declare type: CreationOptional<LiteralUnion<'token-data' | 'coingecko' | 'coinmarketcap', string>>;
18
+ declare enabled: CreationOptional<boolean>;
19
+ declare priority: CreationOptional<number>;
20
+ declare status: CreationOptional<LiteralUnion<'active' | 'degraded' | 'paused' | 'inactive', string>>;
21
+ declare paused_reason: string | null;
22
+ declare config: Record<string, any> | null;
23
+ declare last_success_at: Date | null;
24
+ declare last_failure_at: Date | null;
25
+ declare failure_count: CreationOptional<number>;
26
+ declare created_at: CreationOptional<Date>;
27
+ declare updated_at: CreationOptional<Date>;
28
+
29
+ public static initialize(sequelize: any) {
30
+ this.init(
31
+ {
32
+ id: {
33
+ type: DataTypes.STRING(32),
34
+ primaryKey: true,
35
+ allowNull: false,
36
+ defaultValue: nextExchangeRateProviderId,
37
+ },
38
+ name: {
39
+ type: DataTypes.STRING(64),
40
+ allowNull: false,
41
+ unique: true,
42
+ },
43
+ type: {
44
+ type: DataTypes.STRING(32),
45
+ allowNull: false,
46
+ defaultValue: 'token-data',
47
+ },
48
+ enabled: {
49
+ type: DataTypes.BOOLEAN,
50
+ allowNull: false,
51
+ defaultValue: true,
52
+ },
53
+ priority: {
54
+ type: DataTypes.INTEGER,
55
+ allowNull: false,
56
+ defaultValue: 1,
57
+ },
58
+ status: {
59
+ type: DataTypes.ENUM('active', 'degraded', 'paused', 'inactive'),
60
+ allowNull: false,
61
+ defaultValue: 'active',
62
+ },
63
+ paused_reason: {
64
+ type: DataTypes.STRING(512),
65
+ allowNull: true,
66
+ },
67
+ config: {
68
+ type: DataTypes.JSON,
69
+ allowNull: true,
70
+ },
71
+ last_success_at: {
72
+ type: DataTypes.DATE,
73
+ allowNull: true,
74
+ },
75
+ last_failure_at: {
76
+ type: DataTypes.DATE,
77
+ allowNull: true,
78
+ },
79
+ failure_count: {
80
+ type: DataTypes.INTEGER,
81
+ allowNull: false,
82
+ defaultValue: 0,
83
+ },
84
+ created_at: {
85
+ type: DataTypes.DATE,
86
+ defaultValue: DataTypes.NOW,
87
+ allowNull: false,
88
+ },
89
+ updated_at: {
90
+ type: DataTypes.DATE,
91
+ defaultValue: DataTypes.NOW,
92
+ allowNull: false,
93
+ },
94
+ },
95
+ {
96
+ sequelize,
97
+ modelName: 'ExchangeRateProvider',
98
+ tableName: 'exchange_rate_providers',
99
+ createdAt: 'created_at',
100
+ updatedAt: 'updated_at',
101
+ hooks: {
102
+ afterCreate: (model: ExchangeRateProvider, options) =>
103
+ createEvent('ExchangeRateProvider', 'exchange_rate_provider.created', model, options).catch(console.error),
104
+ afterUpdate: (model: ExchangeRateProvider, options) =>
105
+ createEvent('ExchangeRateProvider', 'exchange_rate_provider.updated', model, options).catch(console.error),
106
+ afterDestroy: (model: ExchangeRateProvider, options) =>
107
+ createEvent('ExchangeRateProvider', 'exchange_rate_provider.deleted', model, options).catch(console.error),
108
+ },
109
+ }
110
+ );
111
+ }
112
+
113
+ public static getActiveProviders() {
114
+ return this.findAll({
115
+ where: { enabled: true, status: { [Op.ne]: 'paused' } },
116
+ order: [['priority', 'ASC']],
117
+ });
118
+ }
119
+
120
+ public async recordSuccess() {
121
+ await this.update({
122
+ last_success_at: new Date(),
123
+ failure_count: 0,
124
+ status: 'active',
125
+ });
126
+ }
127
+
128
+ public async recordFailure(reason?: string) {
129
+ const newFailureCount = this.failure_count + 1;
130
+ const updates: any = {
131
+ last_failure_at: new Date(),
132
+ failure_count: newFailureCount,
133
+ };
134
+
135
+ // Auto-pause after 5 consecutive failures
136
+ // BUT: Never pause the last remaining provider to prevent total service outage
137
+ if (newFailureCount >= 5) {
138
+ // Check if there are other active providers
139
+ const otherActiveProviders = await ExchangeRateProvider.count({
140
+ where: {
141
+ id: { [Op.ne]: this.id },
142
+ enabled: true,
143
+ status: { [Op.ne]: 'paused' },
144
+ },
145
+ });
146
+
147
+ // Only pause if there are other providers available
148
+ if (otherActiveProviders > 0) {
149
+ updates.status = 'paused';
150
+ updates.paused_reason = reason || 'Too many consecutive failures';
151
+ } else {
152
+ // Keep as degraded instead of pausing to maintain service availability
153
+ updates.status = 'degraded';
154
+ updates.paused_reason = `${reason || 'Multiple failures'} (last provider, not paused to maintain service)`;
155
+ }
156
+ } else {
157
+ updates.status = 'degraded';
158
+ }
159
+
160
+ await this.update(updates);
161
+ }
162
+
163
+ /**
164
+ * Encrypt sensitive config data (API keys)
165
+ * Should be called before saving to database
166
+ */
167
+ public static encryptConfig(config: Record<string, any> | null): Record<string, any> | null {
168
+ if (!config) return null;
169
+
170
+ const tmp = cloneDeep(config);
171
+
172
+ // Encrypt API key if present
173
+ if (tmp.api_key && typeof tmp.api_key === 'string') {
174
+ tmp.api_key = security.encrypt(tmp.api_key);
175
+ }
176
+
177
+ return tmp;
178
+ }
179
+
180
+ /**
181
+ * Decrypt sensitive config data (API keys)
182
+ * Should be called after reading from database
183
+ */
184
+ public static decryptConfig(config: Record<string, any> | null): Record<string, any> | null {
185
+ if (!config) return null;
186
+
187
+ const tmp = cloneDeep(config);
188
+
189
+ // Decrypt API key if present
190
+ if (tmp.api_key && typeof tmp.api_key === 'string') {
191
+ try {
192
+ tmp.api_key = security.decrypt(tmp.api_key);
193
+ } catch (error) {
194
+ // If decryption fails, the api_key might already be decrypted or invalid
195
+ // Keep the original value
196
+ }
197
+ }
198
+
199
+ return tmp;
200
+ }
201
+
202
+ /**
203
+ * Mask sensitive config data for display
204
+ * Returns config with API key partially masked
205
+ */
206
+ public static maskConfig(config: Record<string, any> | null): Record<string, any> | null {
207
+ if (!config) return null;
208
+
209
+ const tmp = cloneDeep(config);
210
+
211
+ // Mask API key if present (show first 4 and last 4 characters)
212
+ if (tmp.api_key && typeof tmp.api_key === 'string') {
213
+ const key = tmp.api_key;
214
+ if (key.length > 8) {
215
+ tmp.api_key = `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}`;
216
+ } else {
217
+ tmp.api_key = '*'.repeat(key.length);
218
+ }
219
+ }
220
+
221
+ return tmp;
222
+ }
223
+ }
224
+
225
+ export type TExchangeRateProvider = InferAttributes<ExchangeRateProvider>;