payment-kit 1.18.35 → 1.18.36

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.
@@ -25,7 +25,7 @@ export class SubscriptionTrialWillEndSchedule extends BaseSubscriptionScheduleNo
25
25
  },
26
26
  status: 'trialing',
27
27
  },
28
- attributes: ['id', 'current_period_start', 'current_period_end', 'trial_end'],
28
+ attributes: ['id', 'current_period_start', 'current_period_end', 'trial_end', 'status'],
29
29
  raw: true,
30
30
  });
31
31
 
package/api/src/index.ts CHANGED
@@ -43,6 +43,7 @@ import rechargeAccountHandlers from './routes/connect/recharge-account';
43
43
  import { initialize } from './store/models';
44
44
  import { sequelize } from './store/sequelize';
45
45
  import { initUserHandler } from './integrations/blocklet/user';
46
+ import { startUploadBillingInfoListener } from './queues/space';
46
47
 
47
48
  dotenv.config();
48
49
 
@@ -115,6 +116,7 @@ export const server = app.listen(port, (err?: any) => {
115
116
  startCheckoutSessionQueue().then(() => logger.info('checkoutSession queue started'));
116
117
  startNotificationQueue().then(() => logger.info('notification queue started'));
117
118
  startRefundQueue().then(() => logger.info('refund queue started'));
119
+ startUploadBillingInfoListener();
118
120
 
119
121
  if (process.env.BLOCKLET_MODE === 'production') {
120
122
  ensureWebhookRegistered().catch(console.error);
@@ -0,0 +1,235 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import { SpaceClient, GetObjectCommand, PutObjectCommand } from '@blocklet/did-space-js';
3
+ import { blocklet, wallet } from './auth';
4
+ import logger from './logger';
5
+ import env from './env';
6
+ import { streamToString } from './util';
7
+
8
+ // Get user's DID Space endpoint
9
+ export const getEndpointAndSpaceDid = async (userDid: string): Promise<{ endpoint: string; spaceDid: string }> => {
10
+ const { user } = await blocklet.getUser(userDid);
11
+ if (!user) {
12
+ throw new Error('User not found');
13
+ }
14
+ if (!user.didSpace.endpoint) {
15
+ throw new Error(`DID Space endpoint is not set for user ${userDid}`);
16
+ }
17
+ return {
18
+ endpoint: user.didSpace.endpoint,
19
+ spaceDid: user.didSpace.did,
20
+ };
21
+ };
22
+
23
+ // Initialize DID Space client
24
+ export const getSpaceClient = async (userDid: string, endpoint?: string) => {
25
+ let spaceEndpoint = endpoint;
26
+ if (!spaceEndpoint) {
27
+ const result = await getEndpointAndSpaceDid(userDid);
28
+ spaceEndpoint = result.endpoint;
29
+ }
30
+ return new SpaceClient({
31
+ endpoint: spaceEndpoint,
32
+ wallet,
33
+ });
34
+ };
35
+
36
+ // Universal billing information format
37
+ export interface BillingInfo {
38
+ tx_hash: string; // Transaction hash
39
+ timestamp: number; // Transaction timestamp
40
+
41
+ // Basic billing information
42
+ invoice_id: string; // Invoice ID
43
+ category: string; // Bill category
44
+ description?: string; // Bill description: subscription payment, recharge, refund, etc.
45
+
46
+ // Payment information
47
+ amount: string; // Payment amount
48
+ currency: {
49
+ // Payment medium information
50
+ symbol: string; // Currency symbol
51
+ decimal: number; // Decimal places
52
+ type: string; // Payment type (arcblock/ethereum)
53
+ chain_id: string; // Chain ID
54
+ explorer_host: string; // Explorer host
55
+ explorer_tx_url: string; // Explorer transaction URL
56
+ };
57
+
58
+ // Related business information
59
+ related?: {
60
+ type: string; // Relation type (subscription/invoice)
61
+ id: string; // Related entity ID
62
+ name: string; // Related entity name
63
+ status?: string; // Related entity status
64
+ link?: string; // Link to the related entity
65
+ period_start?: number; // Period start time
66
+ period_end?: number; // Period end time
67
+ };
68
+
69
+ customer_did: string; // Customer DID
70
+ tags?: string[]; // User defined tags
71
+ app_pid: string; // App PID
72
+ link?: string; // Link to the related entity
73
+ }
74
+
75
+ // Merge billing information, preserving user customizations
76
+ const mergeBillingInfo = (existing: BillingInfo, updated: Partial<BillingInfo>): BillingInfo => {
77
+ return {
78
+ ...(existing || {}),
79
+ ...updated,
80
+ // Preserve user customizations if they exist
81
+ description: existing?.description || updated?.description,
82
+ category: existing?.category || updated?.category || 'other',
83
+ tags: Array.from(new Set([...(existing.tags || []), ...(updated.tags || [])])),
84
+ };
85
+ };
86
+
87
+ // Upload or update billing information to DID Space
88
+ export const uploadBillingInfo = async (
89
+ userDid: string,
90
+ billingInfo: BillingInfo,
91
+ endpoint?: string
92
+ ): Promise<boolean> => {
93
+ try {
94
+ // Validate required fields
95
+ if (!billingInfo.tx_hash || !billingInfo.invoice_id || !billingInfo.amount) {
96
+ logger.error('Missing required fields in billingInfo');
97
+ return false;
98
+ }
99
+
100
+ const client = await getSpaceClient(userDid, endpoint);
101
+
102
+ const key = `txs/${billingInfo.tx_hash}/metadata.json`;
103
+
104
+ let finalBillingInfo = {
105
+ ...billingInfo,
106
+ app_pid: billingInfo?.app_pid || env.appPid,
107
+ };
108
+
109
+ // Check existing bill
110
+ const existingBill = await client.send(new GetObjectCommand({ key }));
111
+ if (existingBill.statusCode === 200 && existingBill.data) {
112
+ // Merge with existing data if force update
113
+ const existing = JSON.parse(await streamToString(existingBill.data)) as BillingInfo;
114
+ finalBillingInfo = mergeBillingInfo(existing, billingInfo);
115
+ logger.info('Updating existing billing:', { userDid, txHash: billingInfo.tx_hash });
116
+ }
117
+
118
+ const payload = new PutObjectCommand({
119
+ key,
120
+ data: JSON.stringify(finalBillingInfo),
121
+ });
122
+
123
+ const result = await client.send(payload);
124
+
125
+ if (result.statusCode !== 200) {
126
+ logger.error('Upload billing info failed:', result);
127
+ return false;
128
+ }
129
+
130
+ logger.info(existingBill.statusCode === 200 ? 'Bill updated:' : 'Bill created:', {
131
+ userDid,
132
+ txHash: billingInfo.tx_hash,
133
+ });
134
+ return true;
135
+ } catch (error) {
136
+ logger.error('Upload billing info error:', error);
137
+ return false;
138
+ }
139
+ };
140
+
141
+ // Get specific billing information by transaction hash
142
+ export const getBillingInfo = async (
143
+ userDid: string,
144
+ txHash: string,
145
+ endpoint?: string
146
+ ): Promise<BillingInfo | null> => {
147
+ try {
148
+ if (!txHash) {
149
+ logger.error('Transaction hash is required');
150
+ return null;
151
+ }
152
+
153
+ const client = await getSpaceClient(userDid, endpoint);
154
+ const payload = new GetObjectCommand({
155
+ key: `txs/${txHash}/metadata.json`,
156
+ });
157
+
158
+ const result = await client.send(payload);
159
+
160
+ if (result.statusCode !== 200 || !result.data) {
161
+ logger.debug('Bill not found:', { userDid, txHash });
162
+ return null;
163
+ }
164
+
165
+ const billingInfo = JSON.parse(await streamToString(result.data)) as BillingInfo;
166
+
167
+ // Validate parsed data
168
+ if (!billingInfo.tx_hash || !billingInfo.invoice_id) {
169
+ logger.error('Invalid billing data structure:', { userDid, txHash });
170
+ return null;
171
+ }
172
+
173
+ return billingInfo;
174
+ } catch (error) {
175
+ logger.error('Get billing info error:', { userDid, txHash, error });
176
+ return null;
177
+ }
178
+ };
179
+
180
+ // Update specific fields of billing information
181
+ export const updateBillingInfo = async (
182
+ userDid: string,
183
+ txHash: string,
184
+ updates: Partial<BillingInfo>,
185
+ endpoint?: string
186
+ ): Promise<boolean> => {
187
+ try {
188
+ const existing = await getBillingInfo(userDid, txHash, endpoint);
189
+ if (!existing) {
190
+ logger.error('Billing not found for update:', { userDid, txHash });
191
+ return false;
192
+ }
193
+
194
+ const updated = mergeBillingInfo(existing, updates);
195
+ if (!updated.tx_hash || !updated.invoice_id || !updated.amount) {
196
+ logger.error('Invalid billing data structure:', { userDid, txHash });
197
+ return false;
198
+ }
199
+ const result = await uploadBillingInfo(userDid, updated, endpoint);
200
+ return result;
201
+ } catch (error) {
202
+ logger.error('Update billing info error:', { userDid, txHash, error });
203
+ return false;
204
+ }
205
+ };
206
+
207
+ // Batch upload/update billing information
208
+ export const uploadBillingInfoBatch = async (
209
+ userDid: string,
210
+ billingInfoList: BillingInfo[],
211
+ endpoint?: string
212
+ ): Promise<boolean[]> => {
213
+ try {
214
+ if (!Array.isArray(billingInfoList) || billingInfoList.length === 0) {
215
+ logger.error('Invalid billingInfoList:', { userDid });
216
+ return [];
217
+ }
218
+
219
+ // Validate all bills before upload
220
+ const validBills = billingInfoList.filter((bill) => bill.tx_hash && bill.invoice_id && bill.amount);
221
+
222
+ if (validBills.length !== billingInfoList.length) {
223
+ logger.warn('Some bills are invalid and will be skipped');
224
+ }
225
+
226
+ const results = await Promise.all(
227
+ validBills.map((billingInfo) => uploadBillingInfo(userDid, billingInfo, endpoint))
228
+ );
229
+
230
+ return results;
231
+ } catch (error) {
232
+ logger.error('Batch upload error:', { userDid, error });
233
+ return [];
234
+ }
235
+ };
@@ -1,5 +1,6 @@
1
1
  import crypto from 'crypto';
2
-
2
+ import { Readable } from 'stream';
3
+ import { buffer } from 'node:stream/consumers';
3
4
  import { getUrl } from '@blocklet/sdk/lib/component';
4
5
  import env from '@blocklet/sdk/lib/env';
5
6
  import { getWalletDid } from '@blocklet/sdk/lib/did';
@@ -93,6 +94,19 @@ export function createCodeGenerator(prefix: string, size: number = 24) {
93
94
  return prefix ? () => `${prefix}_${generator()}` : generator;
94
95
  }
95
96
 
97
+ export function stringToStream(str: string): Readable {
98
+ const stream = new Readable();
99
+ stream.push(str);
100
+ stream.push(null);
101
+
102
+ return stream;
103
+ }
104
+
105
+ export const streamToString = async (stream: any, encoding?: BufferEncoding): Promise<string> => {
106
+ const data = await buffer(stream);
107
+ return data.toString(encoding);
108
+ };
109
+
96
110
  // FIXME: merge with old metadata
97
111
  export function formatMetadata(metadata?: Record<string, any>): Record<string, any> {
98
112
  if (!metadata) {
@@ -545,3 +559,21 @@ export function formatCurrencyInfo(
545
559
  }
546
560
  return paymentMethod && paymentMethod.type !== 'arcblock' ? `${amountStr} (${paymentMethod.name})` : amountStr;
547
561
  }
562
+
563
+ export function getExplorerTxUrl({
564
+ explorerHost,
565
+ txHash,
566
+ type,
567
+ }: {
568
+ explorerHost: string;
569
+ txHash: string;
570
+ type: LiteralUnion<'ethereum' | 'base' | 'arcblock' | 'bitcoin', string>;
571
+ }) {
572
+ if (!explorerHost || !txHash) {
573
+ return '';
574
+ }
575
+ if (type === 'arcblock') {
576
+ return joinURL(explorerHost, '/txs', txHash);
577
+ }
578
+ return joinURL(explorerHost, '/tx', txHash);
579
+ }
@@ -175,10 +175,13 @@ export const handlePaymentSucceed = async (
175
175
  );
176
176
  }
177
177
 
178
- depositVaultQueue.push({
179
- id: `deposit-vault-${paymentIntent.currency_id}`,
180
- job: { currencyId: paymentIntent.currency_id },
181
- });
178
+ const exist = await depositVaultQueue.get(`deposit-vault-${paymentIntent.currency_id}`);
179
+ if (!exist) {
180
+ depositVaultQueue.push({
181
+ id: `deposit-vault-${paymentIntent.currency_id}`,
182
+ job: { currencyId: paymentIntent.currency_id },
183
+ });
184
+ }
182
185
 
183
186
  let invoice;
184
187
  if (paymentIntent.invoice_id) {
@@ -0,0 +1,661 @@
1
+ import { getUrl } from '@blocklet/sdk';
2
+ import { literal, Op } from 'sequelize';
3
+ import { events } from '../libs/event';
4
+ import logger from '../libs/logger';
5
+ import createQueue from '../libs/queue';
6
+ import { uploadBillingInfo, BillingInfo, getEndpointAndSpaceDid } from '../libs/did-space';
7
+ import {
8
+ Customer,
9
+ EVMChainType,
10
+ Invoice,
11
+ Job,
12
+ PaymentCurrency,
13
+ PaymentIntent,
14
+ PaymentMethod,
15
+ PaymentMethodSettings,
16
+ Refund,
17
+ Subscription,
18
+ TInvoiceExpanded,
19
+ TRefundExpanded,
20
+ } from '../store/models';
21
+ import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
22
+ import env from '../libs/env';
23
+ import dayjs from '../libs/dayjs';
24
+ import { getExplorerTxUrl } from '../libs/util';
25
+
26
+ // Types
27
+ export type SpaceUploadType = 'invoice' | 'refund' | 'customer' | 'customerInvoiceChunk' | 'customerRefundChunk';
28
+ export type SpaceUploadData = {
29
+ invoice: { id: string };
30
+ refund: { id: string };
31
+ customer: { id: string };
32
+ customerInvoiceChunk: {
33
+ customerId: string;
34
+ chunkIndex: number;
35
+ totalChunks: number;
36
+ ids: string[];
37
+ processTime: number;
38
+ };
39
+ customerRefundChunk: {
40
+ customerId: string;
41
+ chunkIndex: number;
42
+ totalChunks: number;
43
+ ids: string[];
44
+ processTime: number;
45
+ };
46
+ };
47
+
48
+ export type SpaceUploadJob = {
49
+ type: SpaceUploadType;
50
+ data: SpaceUploadData[SpaceUploadType];
51
+ };
52
+
53
+ // Utility functions
54
+ const categoryMap = {
55
+ stake: 'stake',
56
+ slash_stake: 'slashStake',
57
+ overdraft_protection: 'fee',
58
+ stake_overdraft_protection: 'stake',
59
+ recharge: 'recharge',
60
+ return_stake: 'returnStake',
61
+ } as const;
62
+
63
+ const getBillingCategory = (billingReason: string): string => {
64
+ if (
65
+ billingReason.includes('stake') ||
66
+ billingReason.includes('recharge') ||
67
+ billingReason === 'overdraft_protection'
68
+ ) {
69
+ return categoryMap[billingReason as keyof typeof categoryMap] || 'payment';
70
+ }
71
+ return 'payment';
72
+ };
73
+
74
+ const createSubscriptionInfo = (subscription: Subscription) => ({
75
+ type: 'subscription' as const,
76
+ id: subscription.id,
77
+ name: subscription.description || subscription.id,
78
+ status: subscription.status,
79
+ period_start: subscription.current_period_start,
80
+ period_end: subscription.current_period_end,
81
+ link: getUrl(`customer/subscription/${subscription.id}`),
82
+ });
83
+
84
+ const handleInvoicePaid = async (invoiceId: string) => {
85
+ const invoice = (await Invoice.findByPk(invoiceId, {
86
+ include: [
87
+ {
88
+ model: Customer,
89
+ as: 'customer',
90
+ attributes: ['did'],
91
+ },
92
+ {
93
+ model: PaymentCurrency,
94
+ as: 'paymentCurrency',
95
+ attributes: ['symbol', 'decimal'],
96
+ },
97
+ {
98
+ model: PaymentMethod,
99
+ as: 'paymentMethod',
100
+ attributes: ['type', 'settings'],
101
+ },
102
+ {
103
+ model: Subscription,
104
+ as: 'subscription',
105
+ attributes: ['id', 'description', 'status', 'current_period_start', 'current_period_end'],
106
+ },
107
+ {
108
+ model: PaymentIntent,
109
+ as: 'paymentIntent',
110
+ },
111
+ ],
112
+ })) as TInvoiceExpanded | null;
113
+
114
+ // Validation
115
+ if (!invoice) {
116
+ logger.info('Upload invoice skipped because invoice not found:', { invoiceId });
117
+ return;
118
+ }
119
+
120
+ if (invoice.metadata?.did_space_uploaded) {
121
+ logger.info('Upload invoice skipped because invoice already uploaded:', { invoiceId });
122
+ return;
123
+ }
124
+
125
+ const paymentDetails = invoice.paymentIntent?.payment_details || invoice.metadata?.payment_details;
126
+ if (!paymentDetails) {
127
+ logger.info('Upload invoice skipped because payment details not found:', { invoiceId });
128
+ return;
129
+ }
130
+
131
+ if (!CHARGE_SUPPORTED_CHAIN_TYPES.includes(invoice?.paymentMethod?.type)) {
132
+ logger.info('Upload invoice skipped because payment method is not supported:', {
133
+ invoiceId,
134
+ paymentMethod: invoice?.paymentMethod?.type,
135
+ });
136
+ return;
137
+ }
138
+
139
+ const txHash = paymentDetails[invoice.paymentMethod?.type].tx_hash;
140
+ if (!txHash) {
141
+ logger.info('Upload invoice skipped because tx hash not found:', { invoiceId });
142
+ return;
143
+ }
144
+
145
+ let spaceDid = null;
146
+ let endpoint = null;
147
+ try {
148
+ const result = await getEndpointAndSpaceDid(invoice.customer?.did);
149
+ spaceDid = result.spaceDid;
150
+ endpoint = result.endpoint;
151
+ } catch (error) {
152
+ logger.info('Customer space endpoint not available:', {
153
+ invoiceId,
154
+ did: invoice.customer?.did,
155
+ error: (error as Error).message,
156
+ });
157
+ return;
158
+ }
159
+
160
+ const methodInfo = (invoice.paymentMethod?.settings as PaymentMethodSettings)?.[
161
+ invoice.paymentMethod?.type as 'arcblock' | EVMChainType
162
+ ];
163
+ // Create billing info
164
+ const billInfo: BillingInfo = {
165
+ tx_hash: txHash,
166
+ timestamp: dayjs(invoice.updated_at).unix(),
167
+ invoice_id: invoice.id,
168
+ category: getBillingCategory(invoice.billing_reason),
169
+ description: invoice.description || 'Payment received',
170
+ amount: invoice.amount_paid,
171
+ currency: {
172
+ symbol: invoice.paymentCurrency?.symbol,
173
+ decimal: invoice.paymentCurrency?.decimal || 18,
174
+ type: invoice.paymentMethod?.type || '',
175
+ chain_id: methodInfo?.chain_id || '',
176
+ explorer_host: methodInfo?.explorer_host || '',
177
+ explorer_tx_url: methodInfo?.explorer_host
178
+ ? getExplorerTxUrl({
179
+ explorerHost: methodInfo?.explorer_host,
180
+ txHash,
181
+ type: invoice.paymentMethod?.type || '',
182
+ })
183
+ : '',
184
+ },
185
+ customer_did: invoice.customer?.did,
186
+ link: getUrl(`customer/invoice/${invoice.id}`),
187
+ app_pid: env.appPid,
188
+ };
189
+
190
+ if (invoice.subscription_id && invoice.subscription) {
191
+ billInfo.related = createSubscriptionInfo(invoice.subscription as Subscription);
192
+ }
193
+
194
+ // Upload billing info
195
+ const result = await uploadBillingInfo(invoice.customer?.did, billInfo, endpoint);
196
+ if (result) {
197
+ // @ts-ignore
198
+ await invoice.update({
199
+ metadata: {
200
+ ...invoice.metadata,
201
+ did_space_uploaded: true,
202
+ did_space: spaceDid,
203
+ },
204
+ });
205
+ logger.info('Successfully uploaded paid invoice:', {
206
+ invoiceId: invoice.id,
207
+ txHash,
208
+ customerDid: invoice.customer?.did,
209
+ });
210
+ } else {
211
+ logger.error('Failed to upload paid invoice:', {
212
+ invoiceId: invoice.id,
213
+ txHash,
214
+ customerDid: invoice.customer?.did,
215
+ });
216
+ }
217
+ };
218
+
219
+ const handleRefundPaid = async (refundId: string) => {
220
+ const refund = (await Refund.findByPk(refundId, {
221
+ include: [
222
+ { model: Customer, as: 'customer', attributes: ['did'] },
223
+ {
224
+ model: PaymentMethod,
225
+ as: 'paymentMethod',
226
+ attributes: ['type', 'settings'],
227
+ },
228
+ {
229
+ model: PaymentIntent,
230
+ as: 'paymentIntent',
231
+ },
232
+ {
233
+ model: PaymentCurrency,
234
+ as: 'paymentCurrency',
235
+ attributes: ['symbol', 'decimal'],
236
+ },
237
+ ],
238
+ })) as TRefundExpanded | null;
239
+ if (!refund) {
240
+ logger.info('Upload refund skipped because refund not found:', { refundId });
241
+ return;
242
+ }
243
+
244
+ if (refund.metadata?.did_space_uploaded) {
245
+ logger.info('Upload refund skipped because refund already uploaded:', { refundId });
246
+ return;
247
+ }
248
+
249
+ if (!CHARGE_SUPPORTED_CHAIN_TYPES.includes(refund.paymentMethod?.type)) {
250
+ logger.info('Upload refund skipped because payment method is not supported:', {
251
+ refundId,
252
+ paymentMethod: refund.paymentMethod?.type,
253
+ });
254
+ return;
255
+ }
256
+ // @ts-ignore
257
+ const txHash = refund?.payment_details?.[refund.paymentMethod?.type]?.tx_hash;
258
+ if (!txHash) {
259
+ logger.info('Upload refund skipped because tx hash not found:', { refundId });
260
+ return;
261
+ }
262
+
263
+ let spaceDid = null;
264
+ let endpoint = null;
265
+ try {
266
+ const result = await getEndpointAndSpaceDid(refund.customer?.did);
267
+ spaceDid = result.spaceDid;
268
+ endpoint = result.endpoint;
269
+ } catch (error) {
270
+ logger.info('Customer space endpoint not available:', {
271
+ refundId,
272
+ did: refund.customer?.did,
273
+ error: (error as Error).message,
274
+ });
275
+ return;
276
+ }
277
+
278
+ let invoice = null;
279
+ if (refund.invoice_id) {
280
+ invoice = await Invoice.findByPk(refund.invoice_id, {
281
+ include: [{ model: Customer, as: 'customer', attributes: ['did'] }],
282
+ });
283
+ }
284
+ const methodInfo = (refund.paymentMethod?.settings as PaymentMethodSettings)?.[
285
+ refund.paymentMethod?.type as 'arcblock' | EVMChainType
286
+ ];
287
+ const billInfo: BillingInfo = {
288
+ tx_hash: txHash,
289
+ timestamp: dayjs(refund.updated_at).unix(),
290
+ invoice_id: refund.id,
291
+ category: 'refund',
292
+ description: refund.description || 'Refund',
293
+ amount: refund.amount,
294
+ currency: {
295
+ symbol: refund.paymentCurrency?.symbol,
296
+ decimal: refund.paymentCurrency?.decimal || 18,
297
+ type: refund.paymentMethod?.type || '',
298
+ chain_id: methodInfo?.chain_id || '',
299
+ explorer_host: methodInfo?.explorer_host || '',
300
+ explorer_tx_url: methodInfo?.explorer_host
301
+ ? getExplorerTxUrl({
302
+ explorerHost: methodInfo?.explorer_host,
303
+ txHash,
304
+ type: refund.paymentMethod?.type || '',
305
+ })
306
+ : '',
307
+ },
308
+ customer_did: refund.customer?.did,
309
+ link: getUrl(`customer/invoice/${refund.invoice_id}`),
310
+ app_pid: env.appPid,
311
+ };
312
+ if (invoice) {
313
+ billInfo.related = {
314
+ type: 'invoice',
315
+ id: invoice.id,
316
+ name: invoice.description || invoice.id,
317
+ link: getUrl(`customer/invoice/${invoice.id}`),
318
+ };
319
+ }
320
+
321
+ const result = await uploadBillingInfo(refund.customer?.did, billInfo, endpoint);
322
+ if (result) {
323
+ // @ts-ignore
324
+ await refund.update({
325
+ metadata: {
326
+ ...refund.metadata,
327
+ did_space_uploaded: true,
328
+ did_space: spaceDid,
329
+ },
330
+ });
331
+ logger.info('Successfully uploaded paid refund:', {
332
+ refundId: refund.id,
333
+ txHash,
334
+ customerDid: refund.customer?.did,
335
+ });
336
+ } else {
337
+ logger.error('Failed to upload paid refund:', { refundId: refund.id, txHash, customerDid: refund.customer?.did });
338
+ }
339
+ };
340
+
341
+ /**
342
+ * create batch tasks for invoice or refund
343
+ * @param type 'invoice' | 'refund'
344
+ * @param customerId customer id
345
+ * @param records records list
346
+ * @param now current timestamp
347
+ * @returns number of new tasks created
348
+ */
349
+ const createBatchTasks = (
350
+ type: 'invoice' | 'refund',
351
+ customerId: string,
352
+ records: { id: string; created_at?: Date }[],
353
+ now: number
354
+ ): { tasksCreated: number; totalRecords: number } => {
355
+ if (records.length === 0) {
356
+ return { tasksCreated: 0, totalRecords: 0 };
357
+ }
358
+
359
+ const recordIds = records.map((record) => record.id);
360
+ const recordLength = recordIds.length;
361
+ const batchSize = 30;
362
+ const chunks = Math.ceil(recordLength / batchSize);
363
+ let tasksCreated = 0;
364
+
365
+ for (let i = 0; i < chunks; i++) {
366
+ const start = i * batchSize;
367
+ const end = Math.min(start + batchSize, recordLength);
368
+ const chunkRecordIds = recordIds.slice(start, end);
369
+ const processTime = new Date(records?.[start]?.created_at || now).getTime();
370
+ const chunkId = `space-${customerId}-${type}-chunk-${i}-${processTime}`;
371
+
372
+ spaceQueue.push({
373
+ id: chunkId,
374
+ job: {
375
+ type: type === 'invoice' ? 'customerInvoiceChunk' : 'customerRefundChunk',
376
+ data: {
377
+ customerId,
378
+ chunkIndex: i,
379
+ totalChunks: chunks,
380
+ ids: chunkRecordIds,
381
+ processTime,
382
+ },
383
+ },
384
+ delay: 60 * (i + 1), // add 1 minute delay for each chunk
385
+ });
386
+
387
+ tasksCreated++;
388
+ }
389
+
390
+ logger.info(`Created ${type} chunk tasks`, {
391
+ customerId,
392
+ chunks,
393
+ totalRecords: recordLength,
394
+ });
395
+
396
+ return {
397
+ tasksCreated,
398
+ totalRecords: recordLength,
399
+ };
400
+ };
401
+
402
+ const syncCustomerBillingToSpace = async (customerId: string) => {
403
+ try {
404
+ const now = Date.now();
405
+ const customer = await Customer.findByPkOrDid(customerId);
406
+ if (!customer) {
407
+ logger.info('Customer not found:', { customerId });
408
+ return;
409
+ }
410
+
411
+ try {
412
+ await getEndpointAndSpaceDid(customer.did);
413
+ } catch (error) {
414
+ logger.info('Customer space endpoint not available:', {
415
+ customerId,
416
+ did: customer.did,
417
+ error: (error as Error).message,
418
+ });
419
+ return;
420
+ }
421
+
422
+ const validMethods = await PaymentMethod.findAll({
423
+ where: {
424
+ type: { [Op.in]: CHARGE_SUPPORTED_CHAIN_TYPES },
425
+ },
426
+ attributes: ['id'],
427
+ });
428
+
429
+ if (!validMethods.length) {
430
+ logger.info('No valid payment methods found', { customerId });
431
+ return;
432
+ }
433
+
434
+ const validMethodIds = validMethods.map((method) => method.id);
435
+
436
+ let invoiceProcessTime = 0;
437
+ let refundProcessTime = 0;
438
+ try {
439
+ // get the latest process time from the queue
440
+ const [invoiceProcessQueue, refundProcessQueue] = await Promise.all([
441
+ Job.findOne({
442
+ where: {
443
+ queue: 'did-space',
444
+ id: { [Op.like]: `space-${customerId}-invoice-chunk-%` },
445
+ cancelled: false,
446
+ },
447
+ order: [['created_at', 'DESC']],
448
+ }),
449
+ Job.findOne({
450
+ where: {
451
+ queue: 'did-space',
452
+ id: { [Op.like]: `space-${customerId}-refund-chunk-%` },
453
+ cancelled: false,
454
+ },
455
+ order: [['created_at', 'DESC']],
456
+ }),
457
+ ]);
458
+
459
+ invoiceProcessTime = invoiceProcessQueue?.job?.data?.processTime ?? 0;
460
+ refundProcessTime = refundProcessQueue?.job?.data?.processTime ?? 0;
461
+ logger.info('Invoice and refund process time', {
462
+ customerId,
463
+ invoiceProcessTime,
464
+ refundProcessTime,
465
+ });
466
+ } catch (error) {
467
+ logger.error('Failed to get invoice and refund process time', { customerId, error });
468
+ }
469
+
470
+ let [invoices, refunds] = await Promise.all([
471
+ Invoice.findAll({
472
+ where: {
473
+ status: 'paid',
474
+ 'metadata.did_space_uploaded': { [Op.not]: true },
475
+ customer_id: customer.id,
476
+ default_payment_method_id: { [Op.in]: validMethodIds },
477
+ // only process invoices after the latest process time
478
+ created_at: { [Op.gte]: invoiceProcessTime },
479
+ },
480
+ attributes: ['id', 'metadata', 'payment_intent_id', 'created_at'],
481
+ order: [['created_at', 'DESC']],
482
+ }),
483
+ Refund.findAll({
484
+ where: {
485
+ status: 'succeeded',
486
+ 'metadata.did_space_uploaded': { [Op.not]: true },
487
+ customer_id: customer.id,
488
+ payment_method_id: { [Op.in]: validMethodIds },
489
+ [Op.and]: [literal('payment_details IS NOT NULL')],
490
+ // only process refunds after the latest process time
491
+ created_at: { [Op.gte]: refundProcessTime },
492
+ },
493
+ attributes: ['id', 'metadata', 'payment_details', 'created_at'],
494
+ order: [['created_at', 'DESC']],
495
+ }),
496
+ ]);
497
+
498
+ invoices = invoices.filter((x) => x.metadata?.payment_details || x.payment_intent_id);
499
+ refunds = refunds.filter((x) => x.payment_details);
500
+
501
+ const invoicesLength = invoices.length;
502
+ const refundsLength = refunds.length;
503
+ logger.info('Found records to upload:', {
504
+ customerId,
505
+ did: customer.did,
506
+ invoiceCount: invoicesLength,
507
+ refundCount: refundsLength,
508
+ });
509
+
510
+ if (invoicesLength === 0 && refundsLength === 0) {
511
+ logger.info('No records to process', { customerId });
512
+ return;
513
+ }
514
+
515
+ const invoiceChunks = createBatchTasks('invoice', customerId, invoices, now);
516
+
517
+ const refundChunks = createBatchTasks('refund', customerId, refunds, now);
518
+
519
+ logger.info('Completed creating all chunk tasks', {
520
+ customerId,
521
+ invoiceChunks: invoiceChunks.tasksCreated,
522
+ refundChunks: refundChunks.tasksCreated,
523
+ totalRecords: invoicesLength + refundsLength,
524
+ });
525
+ } catch (error) {
526
+ logger.error('Failed to create chunk tasks:', {
527
+ customerId,
528
+ error: error instanceof Error ? error.message : String(error),
529
+ });
530
+ }
531
+ };
532
+
533
+ const handleCustomerInvoiceChunk = async (data: SpaceUploadData['customerInvoiceChunk']) => {
534
+ const { customerId, chunkIndex, totalChunks, ids: invoiceIds } = data;
535
+
536
+ logger.info('Processing invoice chunk', {
537
+ customerId,
538
+ chunkIndex: chunkIndex + 1,
539
+ totalChunks,
540
+ invoiceCount: invoiceIds.length,
541
+ });
542
+
543
+ try {
544
+ await Promise.all(
545
+ invoiceIds.map((invoiceId) =>
546
+ handleInvoicePaid(invoiceId).catch((error) => {
547
+ logger.error('Failed to process invoice:', {
548
+ customerId,
549
+ invoiceId,
550
+ error: error.message,
551
+ });
552
+ })
553
+ )
554
+ );
555
+
556
+ logger.info('Completed invoice chunk processing', {
557
+ customerId,
558
+ chunkIndex: chunkIndex + 1,
559
+ totalChunks,
560
+ processedInvoices: invoiceIds.length,
561
+ });
562
+ } catch (error) {
563
+ logger.error('Invoice chunk processing failed:', {
564
+ customerId,
565
+ chunkIndex,
566
+ error: error instanceof Error ? error.message : String(error),
567
+ });
568
+ }
569
+ };
570
+
571
+ const handleCustomerRefundChunk = async (data: SpaceUploadData['customerRefundChunk']) => {
572
+ const { customerId, chunkIndex, totalChunks, ids: refundIds } = data;
573
+
574
+ logger.info('Processing refund chunk', {
575
+ customerId,
576
+ chunkIndex: chunkIndex + 1,
577
+ totalChunks,
578
+ refundCount: refundIds.length,
579
+ });
580
+
581
+ try {
582
+ await Promise.all(
583
+ refundIds.map((refundId) =>
584
+ handleRefundPaid(refundId).catch((error) => {
585
+ logger.error('Failed to process refund:', {
586
+ customerId,
587
+ refundId,
588
+ error: error.message,
589
+ });
590
+ })
591
+ )
592
+ );
593
+
594
+ logger.info('Completed refund chunk processing', {
595
+ customerId,
596
+ chunkIndex: chunkIndex + 1,
597
+ totalChunks,
598
+ processedRefunds: refundIds.length,
599
+ });
600
+ } catch (error) {
601
+ logger.error('Refund chunk processing failed:', {
602
+ customerId,
603
+ chunkIndex,
604
+ error: error instanceof Error ? error.message : String(error),
605
+ });
606
+ }
607
+ };
608
+
609
+ const handlers = {
610
+ invoice: (data: SpaceUploadData['invoice']) => handleInvoicePaid(data.id),
611
+ refund: (data: SpaceUploadData['refund']) => handleRefundPaid(data.id),
612
+ customer: (data: SpaceUploadData['customer']) => syncCustomerBillingToSpace(data.id),
613
+ customerInvoiceChunk: (data: SpaceUploadData['customerInvoiceChunk']) => handleCustomerInvoiceChunk(data),
614
+ customerRefundChunk: (data: SpaceUploadData['customerRefundChunk']) => handleCustomerRefundChunk(data),
615
+ };
616
+
617
+ export const handleSpaceUpload = async (job: SpaceUploadJob) => {
618
+ logger.info('Starting to handle space upload', job);
619
+ const handler = handlers[job.type];
620
+ if (!handler) {
621
+ logger.error('No handler found for job type', { job });
622
+ return;
623
+ }
624
+ // @ts-ignore
625
+ await handler(job.data);
626
+ };
627
+
628
+ export const spaceQueue = createQueue<SpaceUploadJob>({
629
+ name: 'did-space',
630
+ onJob: handleSpaceUpload,
631
+ options: {
632
+ concurrency: 5,
633
+ maxRetries: 3,
634
+ enableScheduledJob: true,
635
+ },
636
+ });
637
+
638
+ spaceQueue.on('failed', ({ id, job, error }) => {
639
+ logger.error('Space upload job failed', { id, job, error });
640
+ });
641
+
642
+ export const startUploadBillingInfoListener = () => {
643
+ events.on('invoice.paid', (invoice) => {
644
+ spaceQueue.push({
645
+ id: `space-${invoice.id}`,
646
+ job: {
647
+ type: 'invoice',
648
+ data: { id: invoice.id },
649
+ },
650
+ });
651
+ });
652
+
653
+ events.on('refund.succeeded', (refund) => {
654
+ spaceQueue.push({
655
+ id: `space-${refund.id}`,
656
+ job: { type: 'refund', data: { id: refund.id } },
657
+ });
658
+ });
659
+
660
+ logger.info('Space upload listeners started');
661
+ };
@@ -133,7 +133,7 @@ const doHandleSubscriptionInvoice = async ({
133
133
  const usageReportStart = usageStart || start - offset;
134
134
  const usageReportEnd = usageEnd || end - offset;
135
135
 
136
- if (subscription.status !== 'trialing') {
136
+ if (subscription.status !== 'trialing' && reason !== 'recover') {
137
137
  // check if usage report is empty
138
138
  const usageReportEmpty = await checkUsageReportEmpty(subscription, usageReportStart, usageReportEnd);
139
139
  if (usageReportEmpty) {
@@ -25,6 +25,8 @@ import {
25
25
  import { getSubscriptionPaymentAddress, calculateRecommendedRechargeAmount } from '../libs/subscription';
26
26
  import { expandLineItems } from '../libs/session';
27
27
  import { handleNotificationPreferenceChange } from '../queues/notification';
28
+ import { getEndpointAndSpaceDid } from '../libs/did-space';
29
+ import { spaceQueue } from '../queues/space';
28
30
 
29
31
  const router = Router();
30
32
  const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
@@ -172,6 +174,51 @@ router.get('/me', sessionMiddleware(), async (req, res) => {
172
174
  }
173
175
  });
174
176
 
177
+ router.post('/sync-to-space', sessionMiddleware(), async (req, res) => {
178
+ if (!req.user) {
179
+ return res.status(403).json({ error: 'Unauthorized' });
180
+ }
181
+ try {
182
+ const userDid = req.user.did;
183
+ const jobId = `space-${userDid}`;
184
+ const { endpoint } = await getEndpointAndSpaceDid(userDid);
185
+ if (endpoint) {
186
+ const mainTask = await spaceQueue.get(jobId);
187
+ if (mainTask) {
188
+ return res.json({
189
+ success: true,
190
+ message: 'Billing data sync already in progress',
191
+ });
192
+ }
193
+ spaceQueue.push({
194
+ id: jobId,
195
+ job: {
196
+ type: 'customer',
197
+ data: {
198
+ id: userDid,
199
+ },
200
+ },
201
+ delay: 60, // delay 1min
202
+ });
203
+ logger.info('Queued billing sync to DID Space for user:', { did: req.user.did });
204
+
205
+ return res.json({
206
+ success: true,
207
+ message: 'Billing data sync will start soon',
208
+ });
209
+ }
210
+ return res.json({
211
+ success: false,
212
+ message: 'No endpoint found for the user',
213
+ });
214
+ } catch (error) {
215
+ return res.json({
216
+ success: false,
217
+ message: error.message,
218
+ });
219
+ }
220
+ });
221
+
175
222
  // get overdue invoices
176
223
  router.get('/:id/overdue/invoices', sessionMiddleware(), async (req, res) => {
177
224
  if (!req.user) {
@@ -298,7 +298,12 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
298
298
  'subscription_cancel',
299
299
  'subscription',
300
300
  'manual',
301
- 'upcoming'
301
+ 'upcoming',
302
+ 'slash_stake',
303
+ 'stake',
304
+ 'overdraft_protection',
305
+ 'stake_overdraft_protection',
306
+ 'recharge'
302
307
  ),
303
308
  },
304
309
  custom_fields: {
@@ -469,8 +474,12 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
469
474
  createdAt: 'created_at',
470
475
  updatedAt: 'updated_at',
471
476
  hooks: {
472
- afterCreate: (model: Invoice, options) =>
473
- createEvent('Invoice', 'invoice.created', model, options).catch(console.error),
477
+ afterCreate: (model: Invoice, options) => {
478
+ createEvent('Invoice', 'invoice.created', model, options).catch(console.error);
479
+ if (model.status === 'paid') {
480
+ createEvent('Invoice', 'invoice.paid', model, options).catch(console.error);
481
+ }
482
+ },
474
483
  afterUpdate: (model: Invoice, options) => {
475
484
  createEvent('Invoice', 'invoice.updated', model, options).catch(console.error);
476
485
  createStatusEvent(
@@ -13,8 +13,14 @@ import {
13
13
  getSubscriptionNotificationCustomActions,
14
14
  isUserInBlocklist,
15
15
  api,
16
+ md5,
17
+ safeJsonParse,
18
+ getExplorerLink,
19
+ resolveAddressChainTypes,
20
+ formatCurrencyInfo,
21
+ getExplorerTxUrl,
16
22
  } from '../../src/libs/util';
17
- import type { Subscription, PaymentMethod } from '../../src/store/models';
23
+ import type { Subscription, PaymentMethod, PaymentCurrency } from '../../src/store/models';
18
24
 
19
25
  import { blocklet } from '../../src/libs/auth';
20
26
  import logger from '../../src/libs/logger';
@@ -653,3 +659,211 @@ describe('isUserInBlocklist', () => {
653
659
  expect(result).toBe(true);
654
660
  });
655
661
  });
662
+
663
+ describe('md5', () => {
664
+ it('should generate a consistent hash for the same input', () => {
665
+ const input = 'test-string';
666
+ const result1 = md5(input);
667
+ const result2 = md5(input);
668
+ expect(result1).toBe(result2);
669
+ });
670
+
671
+ it('should generate different hashes for different inputs', () => {
672
+ const result1 = md5('test-string-1');
673
+ const result2 = md5('test-string-2');
674
+ expect(result1).not.toBe(result2);
675
+ });
676
+
677
+ it('should return a 32 character hexadecimal string', () => {
678
+ const result = md5('test-string');
679
+ expect(result).toMatch(/^[0-9a-f]{32}$/);
680
+ });
681
+ });
682
+
683
+ describe('safeJsonParse', () => {
684
+ it('should parse valid JSON strings', () => {
685
+ const input = '{"key":"value","number":123}';
686
+ const result = safeJsonParse(input, null);
687
+ expect(result).toEqual({ key: 'value', number: 123 });
688
+ });
689
+
690
+ it('should return the default value when parsing invalid JSON', () => {
691
+ const input = '{invalid json}';
692
+ const defaultValue = { default: true };
693
+ const result = safeJsonParse(input, defaultValue);
694
+ expect(result).toEqual(defaultValue);
695
+ });
696
+
697
+ it('should return the input when parsing fails and no default value is provided', () => {
698
+ const input = '{invalid json}';
699
+ const result = safeJsonParse(input, undefined);
700
+ expect(result).toBe(input);
701
+ });
702
+ });
703
+
704
+ describe('getExplorerLink', () => {
705
+ it('should return undefined when chainHost is not provided', () => {
706
+ const result = getExplorerLink({
707
+ type: 'asset',
708
+ did: 'did:example:123',
709
+ chainHost: undefined,
710
+ });
711
+ expect(result).toBeUndefined();
712
+ });
713
+
714
+ it('should construct a proper asset URL with the given parameters', () => {
715
+ const result = getExplorerLink({
716
+ type: 'asset',
717
+ did: 'did:example:123',
718
+ chainHost: 'https://chain.example.com',
719
+ });
720
+ expect(result).toBe('https://chain.example.com/explorer/assets/did:example:123');
721
+ });
722
+
723
+ it('should construct a proper account URL with the given parameters', () => {
724
+ const result = getExplorerLink({
725
+ type: 'account',
726
+ did: 'did:example:123',
727
+ chainHost: 'https://chain.example.com',
728
+ });
729
+ expect(result).toBe('https://chain.example.com/explorer/accounts/did:example:123');
730
+ });
731
+
732
+ it('should append query parameters when provided', () => {
733
+ const result = getExplorerLink({
734
+ type: 'tx',
735
+ did: 'txhash123',
736
+ chainHost: 'https://chain.example.com',
737
+ queryParams: { foo: 'bar', baz: 'qux' },
738
+ });
739
+ expect(result).toBe('https://chain.example.com/explorer/txs/txhash123?foo=bar&baz=qux');
740
+ });
741
+
742
+ it('should construct a default URL when type is not recognized', () => {
743
+ const result = getExplorerLink({
744
+ type: 'unknown',
745
+ did: 'did:example:123',
746
+ chainHost: 'https://chain.example.com',
747
+ });
748
+ expect(result).toBe('https://chain.example.com/');
749
+ });
750
+
751
+ it('should handle invalid chain host URLs', () => {
752
+ const result = getExplorerLink({
753
+ type: 'asset',
754
+ did: 'did:example:123',
755
+ chainHost: 'invalid-url',
756
+ });
757
+ expect(result).toBeUndefined();
758
+ });
759
+ });
760
+
761
+ describe('resolveAddressChainTypes', () => {
762
+ it('should return ethereum, base, and arcblock for Ethereum addresses', () => {
763
+ const result = resolveAddressChainTypes('0x71C7656EC7ab88b098defB751B7401B5f6d8976F');
764
+ expect(result).toEqual(['ethereum', 'base', 'arcblock']);
765
+ });
766
+
767
+ it('should return arcblock for non-Ethereum addresses', () => {
768
+ const result = resolveAddressChainTypes('did:abt:z1muQ3xqHQK2uiACHyChikobsiY5kLqtShA');
769
+ expect(result).toEqual(['arcblock']);
770
+ });
771
+ });
772
+
773
+ describe('formatCurrencyInfo', () => {
774
+ it('should format amount with currency symbol when isToken is true', () => {
775
+ const paymentCurrency = { symbol: 'ETH', decimal: 18 } as PaymentCurrency;
776
+ const result = formatCurrencyInfo('10', paymentCurrency, null, true);
777
+ expect(result).toBe('10 ETH');
778
+ });
779
+
780
+ it('should use fromUnitToToken to format amount when isToken is false', () => {
781
+ const paymentCurrency = { symbol: 'ETH', decimal: 18 } as PaymentCurrency;
782
+ // Mock the fromUnitToToken function to return a predictable value
783
+ // In a real test, you might use jest.mock to mock this dependency
784
+ // For this example, we'll assume fromUnitToToken converts correctly
785
+ const result = formatCurrencyInfo('1000000000000000000', paymentCurrency, null);
786
+ // In reality, this would be '1 ETH'
787
+ expect(result).toContain('ETH');
788
+ });
789
+
790
+ it('should include payment method name for non-arcblock payment methods', () => {
791
+ const paymentCurrency = { symbol: 'USD', decimal: 2 } as PaymentCurrency;
792
+ const paymentMethod = { type: 'stripe', name: 'Credit Card' } as PaymentMethod;
793
+ const result = formatCurrencyInfo('1000', paymentCurrency, paymentMethod, true);
794
+ expect(result).toBe('1000 USD (Credit Card)');
795
+ });
796
+
797
+ it('should not include payment method name for arcblock payment methods', () => {
798
+ const paymentCurrency = { symbol: 'ABT', decimal: 18 } as PaymentCurrency;
799
+ const paymentMethod = { type: 'arcblock', name: 'ArcBlock' } as PaymentMethod;
800
+ const result = formatCurrencyInfo('5', paymentCurrency, paymentMethod, true);
801
+ expect(result).toBe('5 ABT');
802
+ });
803
+
804
+ it('should handle undefined or empty amount', () => {
805
+ const paymentCurrency = { symbol: 'ETH', decimal: 18 } as PaymentCurrency;
806
+ const result = formatCurrencyInfo('', paymentCurrency, null, true);
807
+ expect(result).toBe('0 ETH');
808
+ });
809
+
810
+ it('should use default values when currency information is missing', () => {
811
+ const paymentCurrency = {} as PaymentCurrency;
812
+ const result = formatCurrencyInfo('10', paymentCurrency, null, true);
813
+ expect(result).toBe('10 ');
814
+ });
815
+ });
816
+
817
+ describe('getExplorerTxUrl', () => {
818
+ it('should return empty string when explorerHost or txHash is not provided', () => {
819
+ const result1 = getExplorerTxUrl({
820
+ explorerHost: '',
821
+ txHash: 'hash123',
822
+ type: 'ethereum',
823
+ });
824
+ expect(result1).toBe('');
825
+
826
+ const result2 = getExplorerTxUrl({
827
+ explorerHost: 'https://explorer.example.com',
828
+ txHash: '',
829
+ type: 'ethereum',
830
+ });
831
+ expect(result2).toBe('');
832
+ });
833
+
834
+ it('should construct a proper URL for arcblock type', () => {
835
+ const result = getExplorerTxUrl({
836
+ explorerHost: 'https://explorer.example.com',
837
+ txHash: 'hash123',
838
+ type: 'arcblock',
839
+ });
840
+ expect(result).toBe('https://explorer.example.com/txs/hash123');
841
+ });
842
+
843
+ it('should construct a proper URL for non-arcblock types', () => {
844
+ const result = getExplorerTxUrl({
845
+ explorerHost: 'https://explorer.example.com',
846
+ txHash: 'hash123',
847
+ type: 'ethereum',
848
+ });
849
+ expect(result).toBe('https://explorer.example.com/tx/hash123');
850
+ });
851
+
852
+ it('should handle different blockchain types correctly', () => {
853
+ const testCases = [
854
+ { type: 'ethereum', expected: '/tx/' },
855
+ { type: 'base', expected: '/tx/' },
856
+ { type: 'bitcoin', expected: '/tx/' },
857
+ { type: 'arcblock', expected: '/txs/' },
858
+ ];
859
+
860
+ testCases.forEach(({ type, expected }) => {
861
+ const result = getExplorerTxUrl({
862
+ explorerHost: 'https://explorer.example.com',
863
+ txHash: 'hash123',
864
+ type: type as any,
865
+ });
866
+ expect(result).toContain(expected);
867
+ });
868
+ });
869
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.18.35
17
+ version: 1.18.36
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -71,6 +71,7 @@ capabilities:
71
71
  navigation: true
72
72
  clusterMode: false
73
73
  component: true
74
+ didSpace: requiredOnConnect
74
75
  screenshots:
75
76
  - setting.png
76
77
  - payment.png
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.18.35",
3
+ "version": "1.18.36",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -47,18 +47,19 @@
47
47
  "@abtnode/cron": "^1.16.42",
48
48
  "@arcblock/did": "^1.20.2",
49
49
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
50
- "@arcblock/did-connect": "^2.13.12",
50
+ "@arcblock/did-connect": "^2.13.13",
51
51
  "@arcblock/did-util": "^1.20.2",
52
52
  "@arcblock/jwt": "^1.20.2",
53
- "@arcblock/ux": "^2.13.12",
53
+ "@arcblock/ux": "^2.13.13",
54
54
  "@arcblock/validator": "^1.20.2",
55
+ "@blocklet/did-space-js": "^1.0.48",
55
56
  "@blocklet/js-sdk": "^1.16.42",
56
57
  "@blocklet/logger": "^1.16.42",
57
- "@blocklet/payment-react": "1.18.35",
58
+ "@blocklet/payment-react": "1.18.36",
58
59
  "@blocklet/sdk": "^1.16.42",
59
- "@blocklet/ui-react": "^2.13.12",
60
- "@blocklet/uploader": "^0.1.83",
61
- "@blocklet/xss": "^0.1.32",
60
+ "@blocklet/ui-react": "^2.13.13",
61
+ "@blocklet/uploader": "^0.1.84",
62
+ "@blocklet/xss": "^0.1.33",
62
63
  "@mui/icons-material": "^5.16.6",
63
64
  "@mui/lab": "^5.0.0-alpha.173",
64
65
  "@mui/material": "^5.16.6",
@@ -122,7 +123,7 @@
122
123
  "devDependencies": {
123
124
  "@abtnode/types": "^1.16.42",
124
125
  "@arcblock/eslint-config-ts": "^0.3.3",
125
- "@blocklet/payment-types": "1.18.35",
126
+ "@blocklet/payment-types": "1.18.36",
126
127
  "@types/cookie-parser": "^1.4.7",
127
128
  "@types/cors": "^2.8.17",
128
129
  "@types/debug": "^4.1.12",
@@ -168,5 +169,5 @@
168
169
  "parser": "typescript"
169
170
  }
170
171
  },
171
- "gitHead": "802a98b5ca81475f8cd7b9dcbb77fce7240b9788"
172
+ "gitHead": "98872e06bac0c437b53a6648ce4487e9f6d2336b"
172
173
  }