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.
- package/api/src/crons/subscription-trial-will-end.ts +1 -1
- package/api/src/index.ts +2 -0
- package/api/src/libs/did-space.ts +235 -0
- package/api/src/libs/util.ts +33 -1
- package/api/src/queues/payment.ts +7 -4
- package/api/src/queues/space.ts +661 -0
- package/api/src/queues/subscription.ts +1 -1
- package/api/src/routes/customers.ts +47 -0
- package/api/src/store/models/invoice.ts +12 -3
- package/api/tests/libs/util.spec.ts +215 -1
- package/blocklet.yml +2 -1
- package/package.json +10 -9
|
@@ -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
|
+
};
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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.
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
58
|
+
"@blocklet/payment-react": "1.18.36",
|
|
58
59
|
"@blocklet/sdk": "^1.16.42",
|
|
59
|
-
"@blocklet/ui-react": "^2.13.
|
|
60
|
-
"@blocklet/uploader": "^0.1.
|
|
61
|
-
"@blocklet/xss": "^0.1.
|
|
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.
|
|
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": "
|
|
172
|
+
"gitHead": "98872e06bac0c437b53a6648ce4487e9f6d2336b"
|
|
172
173
|
}
|