payment-kit 1.18.12 → 1.18.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/resource.ts +53 -11
- package/api/src/libs/auth.ts +14 -0
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +5 -3
- package/api/src/libs/notification/template/subscription-canceled.ts +3 -3
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +4 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +5 -4
- package/api/src/libs/notification/template/subscription-renewed.ts +2 -1
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +3 -4
- package/api/src/libs/notification/template/subscription-succeeded.ts +2 -1
- package/api/src/libs/notification/template/subscription-upgraded.ts +6 -4
- package/api/src/libs/notification/template/subscription-will-canceled.ts +6 -3
- package/api/src/libs/notification/template/subscription-will-renew.ts +1 -1
- package/api/src/libs/payment.ts +77 -2
- package/api/src/libs/util.ts +8 -0
- package/api/src/queues/payment.ts +50 -1
- package/api/src/queues/payout.ts +297 -0
- package/api/src/routes/checkout-sessions.ts +2 -7
- package/api/src/routes/customers.ts +79 -5
- package/api/src/routes/payment-currencies.ts +117 -1
- package/api/src/routes/payment-methods.ts +19 -9
- package/api/src/routes/subscriptions.ts +15 -9
- package/api/src/store/migrations/20250305-vault-config.ts +21 -0
- package/api/src/store/models/invoice.ts +4 -2
- package/api/src/store/models/payment-currency.ts +14 -0
- package/api/src/store/models/payout.ts +21 -0
- package/api/src/store/models/types.ts +6 -0
- package/blocklet.yml +2 -2
- package/package.json +18 -18
- package/src/app.tsx +117 -121
- package/src/components/actions.tsx +32 -9
- package/src/components/copyable.tsx +2 -2
- package/src/components/customer/overdraft-protection.tsx +1 -0
- package/src/components/layout/admin.tsx +6 -0
- package/src/components/layout/user.tsx +38 -0
- package/src/components/metadata/editor.tsx +7 -1
- package/src/components/metadata/list.tsx +3 -0
- package/src/components/passport/assign.tsx +3 -0
- package/src/components/payment-link/rename.tsx +1 -0
- package/src/components/pricing-table/rename.tsx +1 -0
- package/src/components/product/add-price.tsx +1 -0
- package/src/components/product/edit-price.tsx +1 -0
- package/src/components/product/edit.tsx +1 -0
- package/src/components/subscription/actions/index.tsx +1 -0
- package/src/components/subscription/portal/actions.tsx +27 -5
- package/src/components/subscription/portal/list.tsx +24 -6
- package/src/components/subscription/status.tsx +2 -2
- package/src/libs/util.ts +15 -0
- package/src/locales/en.tsx +42 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/payments/payouts/detail.tsx +47 -38
- package/src/pages/admin/settings/index.tsx +3 -3
- package/src/pages/admin/settings/payment-methods/index.tsx +33 -1
- package/src/pages/admin/settings/vault-config/edit-form.tsx +253 -0
- package/src/pages/admin/settings/vault-config/index.tsx +352 -0
- package/src/pages/customer/index.tsx +247 -154
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/payout/detail.tsx +9 -2
- package/src/pages/customer/recharge.tsx +6 -2
- package/src/pages/customer/subscription/change-payment.tsx +1 -1
- package/src/pages/customer/subscription/change-plan.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +8 -3
- package/src/pages/customer/subscription/embed.tsx +142 -84
- package/src/pages/integrations/donations/edit-form.tsx +0 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import dayjs from '../libs/dayjs';
|
|
2
|
+
import { events } from '../libs/event';
|
|
3
|
+
import logger from '../libs/logger';
|
|
4
|
+
import { getGasPayerExtra } from '../libs/payment';
|
|
5
|
+
import createQueue from '../libs/queue';
|
|
6
|
+
import { wallet, ethWallet } from '../libs/auth';
|
|
7
|
+
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
8
|
+
import { PaymentMethod } from '../store/models/payment-method';
|
|
9
|
+
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
10
|
+
import { Payout } from '../store/models/payout';
|
|
11
|
+
import { EVM_CHAIN_TYPES } from '../libs/constants';
|
|
12
|
+
import type { PaymentError } from '../store/models/types';
|
|
13
|
+
import { getNextRetry, MAX_RETRY_COUNT } from '../libs/util';
|
|
14
|
+
|
|
15
|
+
type PayoutJob = {
|
|
16
|
+
payoutId: string;
|
|
17
|
+
retryOnError?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ValidationResult =
|
|
21
|
+
| { valid: false }
|
|
22
|
+
| { valid: true; payout: Payout; paymentMethod: PaymentMethod; paymentCurrency: PaymentCurrency };
|
|
23
|
+
|
|
24
|
+
// Validate payout and fetch required data
|
|
25
|
+
async function validatePayoutAndFetchData(job: PayoutJob): Promise<ValidationResult> {
|
|
26
|
+
const payout = await Payout.findByPk(job.payoutId);
|
|
27
|
+
if (!payout) {
|
|
28
|
+
logger.warn('Payout not found', { id: job.payoutId });
|
|
29
|
+
return { valid: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (payout.status !== 'pending') {
|
|
33
|
+
logger.warn('Payout status not expected', { id: payout.id, status: payout.status });
|
|
34
|
+
return { valid: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const paymentMethod = await PaymentMethod.findByPk(payout.payment_method_id);
|
|
38
|
+
if (!paymentMethod) {
|
|
39
|
+
logger.warn('PaymentMethod not found', { id: payout.payment_method_id });
|
|
40
|
+
return { valid: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const paymentCurrency = await PaymentCurrency.findByPk(payout.currency_id);
|
|
44
|
+
if (!paymentCurrency) {
|
|
45
|
+
logger.warn('PaymentCurrency not found', { id: payout.currency_id });
|
|
46
|
+
return { valid: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
valid: true,
|
|
51
|
+
payout,
|
|
52
|
+
paymentMethod,
|
|
53
|
+
paymentCurrency,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Process Arcblock chain payout
|
|
58
|
+
async function processArcblockPayout(payout: Payout, paymentMethod: PaymentMethod, paymentCurrency: PaymentCurrency) {
|
|
59
|
+
const client = paymentMethod.getOcapClient();
|
|
60
|
+
|
|
61
|
+
const signed = await client.signTransferV2Tx({
|
|
62
|
+
tx: {
|
|
63
|
+
itx: {
|
|
64
|
+
to: payout.destination,
|
|
65
|
+
value: '0',
|
|
66
|
+
assets: [],
|
|
67
|
+
tokens: [{ address: paymentCurrency.contract, value: payout.amount }],
|
|
68
|
+
data: {
|
|
69
|
+
typeUrl: 'json',
|
|
70
|
+
// @ts-ignore Type issue, won't affect server runtime
|
|
71
|
+
value: {
|
|
72
|
+
appId: wallet.address,
|
|
73
|
+
reason: 'payout',
|
|
74
|
+
payoutId: payout.id,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
wallet,
|
|
80
|
+
});
|
|
81
|
+
// @ts-ignore
|
|
82
|
+
const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
|
|
83
|
+
// @ts-ignore
|
|
84
|
+
const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, getGasPayerExtra(buffer));
|
|
85
|
+
|
|
86
|
+
logger.info('Payout completed', { id: payout.id, txHash });
|
|
87
|
+
|
|
88
|
+
await payout.update({
|
|
89
|
+
status: 'paid',
|
|
90
|
+
last_attempt_error: null,
|
|
91
|
+
attempt_count: payout.attempt_count + 1,
|
|
92
|
+
attempted: true,
|
|
93
|
+
payment_details: {
|
|
94
|
+
arcblock: {
|
|
95
|
+
tx_hash: txHash,
|
|
96
|
+
payer: wallet.address,
|
|
97
|
+
type: 'transfer',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Process EVM chain payout
|
|
104
|
+
async function processEvmPayout(payout: Payout, paymentMethod: PaymentMethod, paymentCurrency: PaymentCurrency) {
|
|
105
|
+
if (!paymentCurrency.contract) {
|
|
106
|
+
throw new Error('Payout not supported for ethereum payment currencies without contract');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const client = paymentMethod.getEvmClient();
|
|
110
|
+
const paymentType = paymentMethod.type;
|
|
111
|
+
|
|
112
|
+
// Send ERC20 tokens from system wallet to user address
|
|
113
|
+
const receipt = await sendErc20ToUser(client, paymentCurrency.contract, payout.destination, payout.amount);
|
|
114
|
+
|
|
115
|
+
logger.info('Payout completed', { id: payout.id, txHash: receipt.hash });
|
|
116
|
+
|
|
117
|
+
await payout.update({
|
|
118
|
+
status: 'paid',
|
|
119
|
+
last_attempt_error: null,
|
|
120
|
+
attempt_count: payout.attempt_count + 1,
|
|
121
|
+
attempted: true,
|
|
122
|
+
payment_details: {
|
|
123
|
+
[paymentType]: {
|
|
124
|
+
tx_hash: receipt.hash,
|
|
125
|
+
payer: ethWallet.address,
|
|
126
|
+
block_height: receipt.blockNumber.toString(),
|
|
127
|
+
gas_used: receipt.gasUsed.toString(),
|
|
128
|
+
gas_price: receipt.gasPrice.toString(),
|
|
129
|
+
type: 'transfer',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Handle payout failure with retry logic
|
|
136
|
+
async function handlePayoutFailure(payout: Payout, paymentMethod: PaymentMethod, error: any, retryOnError: boolean) {
|
|
137
|
+
const paymentError: PaymentError = {
|
|
138
|
+
type: 'card_error',
|
|
139
|
+
code: error.code,
|
|
140
|
+
message: error.message,
|
|
141
|
+
payment_method_id: paymentMethod.id,
|
|
142
|
+
payment_method_type: paymentMethod.type,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (!retryOnError) {
|
|
146
|
+
// Mark as failed without retry
|
|
147
|
+
await payout.update({
|
|
148
|
+
status: 'failed',
|
|
149
|
+
last_attempt_error: paymentError,
|
|
150
|
+
attempt_count: payout.attempt_count + 1,
|
|
151
|
+
attempted: true,
|
|
152
|
+
failure_message: error.message,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const attemptCount = payout.attempt_count + 1;
|
|
158
|
+
|
|
159
|
+
if (attemptCount >= MAX_RETRY_COUNT) {
|
|
160
|
+
// Exceeded max retry count
|
|
161
|
+
await payout.update({
|
|
162
|
+
status: 'failed',
|
|
163
|
+
last_attempt_error: paymentError,
|
|
164
|
+
attempt_count: attemptCount,
|
|
165
|
+
attempted: true,
|
|
166
|
+
failure_message: error.message,
|
|
167
|
+
});
|
|
168
|
+
logger.info('Payout job deleted since max retry exceeded', { id: payout.id });
|
|
169
|
+
payoutQueue.delete(payout.id);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const nextAttempt = getNextRetry(attemptCount);
|
|
174
|
+
await payout.update({
|
|
175
|
+
status: 'pending',
|
|
176
|
+
last_attempt_error: paymentError,
|
|
177
|
+
attempt_count: attemptCount,
|
|
178
|
+
attempted: true,
|
|
179
|
+
next_attempt: nextAttempt,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
payoutQueue.push({
|
|
183
|
+
id: payout.id,
|
|
184
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
185
|
+
runAt: nextAttempt,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
logger.error('Payout retry scheduled', { id: payout.id, nextAttempt, retryCount: attemptCount });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Process payout transaction
|
|
192
|
+
export const handlePayout = async (job: PayoutJob) => {
|
|
193
|
+
logger.info('handle payout', job);
|
|
194
|
+
|
|
195
|
+
const result = await validatePayoutAndFetchData(job);
|
|
196
|
+
if (!result.valid) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { payout, paymentMethod, paymentCurrency } = result;
|
|
201
|
+
|
|
202
|
+
logger.info('Payout attempt', { id: payout.id, attempt: payout.attempt_count });
|
|
203
|
+
try {
|
|
204
|
+
await payout.update({ status: 'in_transit', last_attempt_error: null });
|
|
205
|
+
logger.info('Payout status updated to in_transit', { payoutId: payout.id });
|
|
206
|
+
|
|
207
|
+
if (paymentMethod.type === 'arcblock') {
|
|
208
|
+
await processArcblockPayout(payout, paymentMethod, paymentCurrency);
|
|
209
|
+
} else if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
|
|
210
|
+
await processEvmPayout(payout, paymentMethod, paymentCurrency);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
logger.error('Payout failed', { error: err, id: payout.id });
|
|
214
|
+
await handlePayoutFailure(payout, paymentMethod, err, !!job.retryOnError);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Create queue processor
|
|
219
|
+
export const payoutQueue = createQueue<PayoutJob>({
|
|
220
|
+
name: 'payout',
|
|
221
|
+
onJob: handlePayout,
|
|
222
|
+
options: {
|
|
223
|
+
concurrency: 1,
|
|
224
|
+
maxRetries: 0,
|
|
225
|
+
enableScheduledJob: true,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Handle queue failure events
|
|
230
|
+
payoutQueue.on('failed', ({ id, job, error }) => {
|
|
231
|
+
logger.error('Payout job failed', { id, job, error });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Start queue, find all payouts with "pending" status
|
|
235
|
+
export const startPayoutQueue = async () => {
|
|
236
|
+
const payouts = await Payout.findAll({
|
|
237
|
+
where: {
|
|
238
|
+
status: 'pending',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
payouts.forEach(async (payout) => {
|
|
243
|
+
const exist = await payoutQueue.get(payout.id);
|
|
244
|
+
if (!exist) {
|
|
245
|
+
// Use next attempt time if set
|
|
246
|
+
if (payout.next_attempt && payout.next_attempt > dayjs().unix()) {
|
|
247
|
+
payoutQueue.push({
|
|
248
|
+
id: payout.id,
|
|
249
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
250
|
+
runAt: payout.next_attempt,
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
payoutQueue.push({
|
|
254
|
+
id: payout.id,
|
|
255
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Listen for newly created payouts
|
|
263
|
+
events.on('payout.created', async (payout: Payout) => {
|
|
264
|
+
if (payout.status === 'pending') {
|
|
265
|
+
const exist = await payoutQueue.get(payout.id);
|
|
266
|
+
if (!exist) {
|
|
267
|
+
payoutQueue.push({
|
|
268
|
+
id: payout.id,
|
|
269
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Add synchronous payout processing event
|
|
276
|
+
events.on('payout.queued', async (id, job, args = {}) => {
|
|
277
|
+
const { sync, ...extraArgs } = args;
|
|
278
|
+
if (sync) {
|
|
279
|
+
try {
|
|
280
|
+
await payoutQueue.pushAndWait({
|
|
281
|
+
id,
|
|
282
|
+
job,
|
|
283
|
+
...extraArgs,
|
|
284
|
+
});
|
|
285
|
+
events.emit('payout.queued.done');
|
|
286
|
+
} catch (error) {
|
|
287
|
+
logger.error('Error in payout.queued', { id, job, error });
|
|
288
|
+
events.emit('payout.queued.error', error);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
payoutQueue.push({
|
|
293
|
+
id,
|
|
294
|
+
job,
|
|
295
|
+
...extraArgs,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -17,11 +17,7 @@ import { MetadataSchema } from '../libs/api';
|
|
|
17
17
|
import { checkPassportForPaymentLink } from '../integrations/blocklet/passport';
|
|
18
18
|
import { handleStripePaymentSucceed } from '../integrations/stripe/handlers/payment-intent';
|
|
19
19
|
import { handleStripeSubscriptionSucceed } from '../integrations/stripe/handlers/subscription';
|
|
20
|
-
import {
|
|
21
|
-
ensureStripePaymentCustomer,
|
|
22
|
-
ensureStripePaymentIntent,
|
|
23
|
-
ensureStripeSubscription,
|
|
24
|
-
} from '../integrations/stripe/resource';
|
|
20
|
+
import { ensureStripePaymentIntent, ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
25
21
|
import dayjs from '../libs/dayjs';
|
|
26
22
|
import logger from '../libs/logger';
|
|
27
23
|
import { isCreditSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
|
|
@@ -1048,12 +1044,11 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1048
1044
|
trialInDays,
|
|
1049
1045
|
trialEnd
|
|
1050
1046
|
);
|
|
1051
|
-
const stripeCustomer = await ensureStripePaymentCustomer(subscription, paymentMethod);
|
|
1052
1047
|
if (stripeSubscription) {
|
|
1053
1048
|
await subscription.update({
|
|
1054
1049
|
payment_details: {
|
|
1055
1050
|
stripe: {
|
|
1056
|
-
customer_id:
|
|
1051
|
+
customer_id: stripeSubscription.customer,
|
|
1057
1052
|
subscription_id: stripeSubscription.id,
|
|
1058
1053
|
setup_intent_id: stripeSubscription.pending_setup_intent?.id,
|
|
1059
1054
|
},
|
|
@@ -11,6 +11,7 @@ import { formatMetadata } from '../libs/util';
|
|
|
11
11
|
import { Customer } from '../store/models/customer';
|
|
12
12
|
import { blocklet } from '../libs/auth';
|
|
13
13
|
import logger from '../libs/logger';
|
|
14
|
+
import { Invoice } from '../store/models';
|
|
14
15
|
|
|
15
16
|
const router = Router();
|
|
16
17
|
const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -86,26 +87,99 @@ router.get('/me', sessionMiddleware(), async (req, res) => {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
|
-
|
|
90
|
+
let doc = await Customer.findByPkOrDid(req.user.did as string);
|
|
90
91
|
const livemode = req.query.livemode ? !!req?.livemode : !!doc?.livemode;
|
|
91
92
|
if (!doc) {
|
|
92
93
|
if (req.query.fallback) {
|
|
93
94
|
const result = await blocklet.getUser(req.user.did);
|
|
94
|
-
res.json({ ...result.user, address: {}, livemode });
|
|
95
|
+
return res.json({ ...result.user, address: {}, livemode });
|
|
96
|
+
}
|
|
97
|
+
if (req.query.create) {
|
|
98
|
+
// create customer
|
|
99
|
+
const { user } = await blocklet.getUser(req.user.did);
|
|
100
|
+
const customer = await Customer.create({
|
|
101
|
+
livemode: true,
|
|
102
|
+
did: req.user.did,
|
|
103
|
+
name: user.fullName,
|
|
104
|
+
email: user.email,
|
|
105
|
+
phone: '',
|
|
106
|
+
address: {},
|
|
107
|
+
description: user.remark,
|
|
108
|
+
metadata: {},
|
|
109
|
+
balance: '0',
|
|
110
|
+
next_invoice_sequence: 1,
|
|
111
|
+
delinquent: false,
|
|
112
|
+
invoice_prefix: Customer.getInvoicePrefix(),
|
|
113
|
+
});
|
|
114
|
+
logger.info('customer created', {
|
|
115
|
+
customerId: customer.id,
|
|
116
|
+
did: customer.did,
|
|
117
|
+
});
|
|
118
|
+
doc = customer;
|
|
95
119
|
} else {
|
|
96
|
-
res.json({ error: 'Customer not found' });
|
|
120
|
+
return res.json({ error: 'Customer not found' });
|
|
97
121
|
}
|
|
98
|
-
}
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
99
124
|
const [summary, stake, token] = await Promise.all([
|
|
100
125
|
doc.getSummary(livemode),
|
|
101
126
|
getStakeSummaryByDid(doc.did, livemode),
|
|
102
127
|
getTokenSummaryByDid(doc.did, livemode),
|
|
103
128
|
]);
|
|
104
129
|
res.json({ ...doc.toJSON(), summary: { ...summary, stake, token }, livemode });
|
|
130
|
+
} catch (summaryErr) {
|
|
131
|
+
logger.error('get customer summary failed', summaryErr);
|
|
132
|
+
if (req.query.skipError) {
|
|
133
|
+
return res.json({
|
|
134
|
+
...doc.toJSON(),
|
|
135
|
+
summary: { stake: {}, token: {} },
|
|
136
|
+
livemode,
|
|
137
|
+
summaryError: summaryErr.message,
|
|
138
|
+
error: `Failed to get customer: ${summaryErr.message}`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
throw summaryErr;
|
|
105
142
|
}
|
|
106
143
|
} catch (err) {
|
|
107
144
|
logger.error('get customer failed', err);
|
|
108
|
-
|
|
145
|
+
if (req.query.skipError) {
|
|
146
|
+
return res.json({
|
|
147
|
+
error: `Failed to get customer: ${err.message}`,
|
|
148
|
+
did: req.user?.did,
|
|
149
|
+
name: req.user?.fullName,
|
|
150
|
+
address: {},
|
|
151
|
+
livemode: !!req.query.livemode,
|
|
152
|
+
summary: { stake: {}, token: {} },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return res.status(500).json({ error: `Failed to get customer: ${err.message}` });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// get overdue invoices
|
|
160
|
+
router.get('/:id/overdue/invoices', auth, async (req, res) => {
|
|
161
|
+
if (!req.user) {
|
|
162
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const doc = await Customer.findByPkOrDid(req.params.id as string);
|
|
166
|
+
if (!doc) {
|
|
167
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
168
|
+
}
|
|
169
|
+
const [summary, detail, invoices] = await Invoice!.getUncollectibleAmount({
|
|
170
|
+
customerId: doc.id,
|
|
171
|
+
livemode: req.query.livemode ? !!req.query.livemode : doc.livemode,
|
|
172
|
+
});
|
|
173
|
+
const subscriptionCount = new Set(invoices.map((x) => x.subscription_id)).size;
|
|
174
|
+
return res.json({
|
|
175
|
+
summary,
|
|
176
|
+
invoices,
|
|
177
|
+
subscriptionCount,
|
|
178
|
+
detail,
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
logger.error(err);
|
|
182
|
+
return res.status(500).json({ error: `Failed to get overdue invoices: ${err.message}` });
|
|
109
183
|
}
|
|
110
184
|
});
|
|
111
185
|
|
|
@@ -9,11 +9,16 @@ import { authenticate } from '../libs/security';
|
|
|
9
9
|
import { PaymentCurrency, TPaymentCurrency } from '../store/models/payment-currency';
|
|
10
10
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
11
11
|
import { EVM_CHAIN_TYPES } from '../libs/constants';
|
|
12
|
+
import { ethWallet, getVaultAddress, wallet } from '../libs/auth';
|
|
13
|
+
import { resolveAddressChainTypes } from '../libs/util';
|
|
14
|
+
import { depositVaultQueue } from '../queues/payment';
|
|
15
|
+
import { checkDepositVaultAmount } from '../libs/payment';
|
|
16
|
+
import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
|
|
12
17
|
|
|
13
18
|
const router = Router();
|
|
14
19
|
|
|
15
20
|
const auth = authenticate<PaymentCurrency>({ component: true, roles: ['owner', 'admin'] });
|
|
16
|
-
|
|
21
|
+
const authOwner = authenticate<PaymentCurrency>({ component: true, roles: ['owner'] });
|
|
17
22
|
router.post('/', auth, async (req, res) => {
|
|
18
23
|
const raw: Partial<TPaymentCurrency> = req.body;
|
|
19
24
|
|
|
@@ -140,6 +145,81 @@ router.get('/', auth, async (req, res) => {
|
|
|
140
145
|
res.json(list);
|
|
141
146
|
});
|
|
142
147
|
|
|
148
|
+
router.get('/vault-config', auth, async (req, res) => {
|
|
149
|
+
const vaultAddress = await getVaultAddress();
|
|
150
|
+
if (!vaultAddress) {
|
|
151
|
+
return res.json([]);
|
|
152
|
+
}
|
|
153
|
+
const chainTypes = resolveAddressChainTypes(vaultAddress);
|
|
154
|
+
try {
|
|
155
|
+
const paymentMethods = await PaymentMethod.findAll({
|
|
156
|
+
where: {
|
|
157
|
+
type: {
|
|
158
|
+
[Op.in]: chainTypes,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
attributes: ['id'],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const paymentMethodIds = paymentMethods.map((method) => method.id);
|
|
165
|
+
const list = await PaymentCurrency.scope('withVaultConfig').findAll({
|
|
166
|
+
where: {
|
|
167
|
+
payment_method_id: {
|
|
168
|
+
[Op.in]: paymentMethodIds,
|
|
169
|
+
},
|
|
170
|
+
livemode: !!req.livemode,
|
|
171
|
+
},
|
|
172
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
const [arcblock, ethereum] = await Promise.all([
|
|
176
|
+
getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
|
|
177
|
+
getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
|
|
178
|
+
]);
|
|
179
|
+
return res.json({
|
|
180
|
+
list,
|
|
181
|
+
balances: {
|
|
182
|
+
...arcblock,
|
|
183
|
+
...ethereum,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
logger.error('get token summary failed', err);
|
|
188
|
+
return res.status(400).json({ error: err.message, list, balances: {} });
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error('get payment currency vault config failed', err);
|
|
192
|
+
return res.status(400).json({ error: err.message });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
router.get('/:id/deposit-vault', auth, async (req, res) => {
|
|
197
|
+
const { id } = req.params;
|
|
198
|
+
if (!id) {
|
|
199
|
+
return res.status(400).json({ error: 'Missing payment currency id' });
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const result = await checkDepositVaultAmount(id);
|
|
203
|
+
return res.json(result);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger.error('Error checking deposit vault amount', { error, id });
|
|
206
|
+
return res.status(400).json({ error: 'Failed to check deposit vault amount', message: error.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
router.put('/:id/deposit-vault', auth, async (req, res) => {
|
|
211
|
+
const paymentCurrency = await PaymentCurrency.findByPk(req.params.id);
|
|
212
|
+
if (!paymentCurrency) {
|
|
213
|
+
return res.status(404).json({ error: 'Payment currency not found' });
|
|
214
|
+
}
|
|
215
|
+
depositVaultQueue.push({
|
|
216
|
+
id: `deposit-vault-${paymentCurrency.id}`,
|
|
217
|
+
job: { currencyId: paymentCurrency.id },
|
|
218
|
+
});
|
|
219
|
+
logger.info('Deposit vault job pushed', { currencyId: paymentCurrency.id });
|
|
220
|
+
return res.json({ message: 'Deposit vault job pushed' });
|
|
221
|
+
});
|
|
222
|
+
|
|
143
223
|
router.get('/:id', auth, async (req, res) => {
|
|
144
224
|
const doc = await PaymentCurrency.findOne({
|
|
145
225
|
where: { [Op.or]: [{ id: req.params.id }, { symbol: req.params.id }] },
|
|
@@ -152,6 +232,42 @@ router.get('/:id', auth, async (req, res) => {
|
|
|
152
232
|
}
|
|
153
233
|
});
|
|
154
234
|
|
|
235
|
+
const UpdateVaultConfigSchema = Joi.object({
|
|
236
|
+
enabled: Joi.boolean().required(),
|
|
237
|
+
deposit_threshold: Joi.number().greater(0).required(),
|
|
238
|
+
withdraw_threshold: Joi.number().min(0).required(),
|
|
239
|
+
});
|
|
240
|
+
router.put('/:id/vault-config', authOwner, async (req, res) => {
|
|
241
|
+
try {
|
|
242
|
+
const { id } = req.params;
|
|
243
|
+
|
|
244
|
+
const { error, value: vaultConfig } = UpdateVaultConfigSchema.validate(req.body);
|
|
245
|
+
if (error) {
|
|
246
|
+
return res.status(400).json({ error: error.message });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const paymentCurrency = await PaymentCurrency.findByPk(id);
|
|
250
|
+
if (!paymentCurrency) {
|
|
251
|
+
return res.status(404).json({ error: 'payment currency not found' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const updateData: Partial<TPaymentCurrency> = {
|
|
255
|
+
vault_config: {
|
|
256
|
+
enabled: vaultConfig.enabled,
|
|
257
|
+
deposit_threshold: fromTokenToUnit(vaultConfig.deposit_threshold, paymentCurrency.decimal).toString(),
|
|
258
|
+
withdraw_threshold: fromTokenToUnit(vaultConfig.withdraw_threshold, paymentCurrency.decimal).toString(),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
await paymentCurrency.update(updateData);
|
|
263
|
+
|
|
264
|
+
return res.json(paymentCurrency.toJSON());
|
|
265
|
+
} catch (err) {
|
|
266
|
+
logger.error('update payment currency vault config failed', err);
|
|
267
|
+
return res.status(400).json({ error: err.message });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
155
271
|
const updateCurrencySchema = Joi.object({
|
|
156
272
|
name: Joi.string().empty('').optional(),
|
|
157
273
|
description: Joi.string().empty('').optional(),
|
|
@@ -192,15 +192,25 @@ router.get('/', auth, async (req, res) => {
|
|
|
192
192
|
include: [{ model: PaymentCurrency, as: 'payment_currencies', order: [['created_at', 'ASC']] }],
|
|
193
193
|
});
|
|
194
194
|
if (query.addresses === 'true') {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
195
|
+
try {
|
|
196
|
+
const [arcblock, ethereum] = await Promise.all([
|
|
197
|
+
getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
|
|
198
|
+
getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
|
|
199
|
+
]);
|
|
200
|
+
res.json({
|
|
201
|
+
list,
|
|
202
|
+
addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
|
|
203
|
+
balances: { ...arcblock, ...ethereum },
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logger.error('get token summary failed', err.message);
|
|
207
|
+
res.json({
|
|
208
|
+
list,
|
|
209
|
+
addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
|
|
210
|
+
balances: {},
|
|
211
|
+
error: `get token summary failed: ${err.message}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
204
214
|
} else {
|
|
205
215
|
res.json(list);
|
|
206
216
|
}
|
|
@@ -9,12 +9,7 @@ import uniq from 'lodash/uniq';
|
|
|
9
9
|
import { literal, Op, OrderItem } from 'sequelize';
|
|
10
10
|
import { BN } from '@ocap/util';
|
|
11
11
|
import { createEvent } from '../libs/audit';
|
|
12
|
-
import {
|
|
13
|
-
ensureStripeCustomer,
|
|
14
|
-
ensureStripePaymentCustomer,
|
|
15
|
-
ensureStripePrice,
|
|
16
|
-
ensureStripeSubscription,
|
|
17
|
-
} from '../integrations/stripe/resource';
|
|
12
|
+
import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
18
13
|
import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
|
|
19
14
|
import dayjs from '../libs/dayjs';
|
|
20
15
|
import logger from '../libs/logger';
|
|
@@ -97,6 +92,7 @@ const schema = createListParamSchema<{
|
|
|
97
92
|
activeFirst?: boolean;
|
|
98
93
|
price_id?: string;
|
|
99
94
|
order?: string | string[] | OrderItem | OrderItem[];
|
|
95
|
+
showTotalCount?: boolean;
|
|
100
96
|
}>({
|
|
101
97
|
status: Joi.string().empty(''),
|
|
102
98
|
customer_id: Joi.string().empty(''),
|
|
@@ -110,6 +106,7 @@ const schema = createListParamSchema<{
|
|
|
110
106
|
Joi.array().items(Joi.array().ordered(Joi.string(), Joi.string().valid('ASC', 'DESC').insensitive()))
|
|
111
107
|
)
|
|
112
108
|
.optional(),
|
|
109
|
+
showTotalCount: Joi.boolean().optional(),
|
|
113
110
|
});
|
|
114
111
|
|
|
115
112
|
const parseOrder = (orderStr: string): OrderItem => {
|
|
@@ -195,7 +192,17 @@ router.get('/', authMine, async (req, res) => {
|
|
|
195
192
|
// @ts-ignore
|
|
196
193
|
docs.forEach((x) => expandLineItems(x.items, products, prices));
|
|
197
194
|
|
|
198
|
-
|
|
195
|
+
if (query.showTotalCount) {
|
|
196
|
+
const totalCount = await Subscription.count({
|
|
197
|
+
where: {
|
|
198
|
+
customer_id: where.customer_id,
|
|
199
|
+
},
|
|
200
|
+
distinct: true,
|
|
201
|
+
});
|
|
202
|
+
res.json({ count, list: docs, paging: { page, pageSize }, totalCount });
|
|
203
|
+
} else {
|
|
204
|
+
res.json({ count, list: docs, paging: { page, pageSize } });
|
|
205
|
+
}
|
|
199
206
|
} catch (err) {
|
|
200
207
|
logger.error(err);
|
|
201
208
|
res.json({ count: 0, list: [], paging: { page, pageSize } });
|
|
@@ -1528,7 +1535,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
1528
1535
|
const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
|
|
1529
1536
|
|
|
1530
1537
|
// changing from crypto to stripe: create/resume stripe subscription, pause crypto subscription
|
|
1531
|
-
const stripeCustomer = await ensureStripePaymentCustomer(subscription, paymentMethod);
|
|
1532
1538
|
const stripeSubscription = await ensureStripeSubscription(
|
|
1533
1539
|
subscription,
|
|
1534
1540
|
paymentMethod,
|
|
@@ -1542,7 +1548,7 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
1542
1548
|
payment_details: {
|
|
1543
1549
|
...subscription.payment_details,
|
|
1544
1550
|
stripe: {
|
|
1545
|
-
customer_id:
|
|
1551
|
+
customer_id: stripeSubscription.customer,
|
|
1546
1552
|
subscription_id: stripeSubscription.id,
|
|
1547
1553
|
setup_intent_id: stripeSubscription.pending_setup_intent?.id,
|
|
1548
1554
|
},
|