payment-kit 1.24.4 → 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.
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- 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>;
|