payment-kit 1.20.9 → 1.20.11
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/README.md +25 -24
- package/api/src/integrations/stripe/handlers/index.ts +10 -2
- package/api/src/libs/invoice.ts +6 -6
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +17 -10
- package/api/src/libs/vendor-util/fulfillment.ts +15 -121
- package/api/src/locales/en.ts +38 -38
- package/api/src/queues/payment.ts +1 -5
- package/api/src/queues/vendors/commission.ts +16 -14
- package/api/src/queues/vendors/fulfillment-coordinator.ts +2 -2
- package/api/src/queues/vendors/status-check.ts +11 -6
- package/api/src/routes/vendor.ts +20 -1
- package/api/src/store/migrations/20250916-add-vendor-did.ts +20 -0
- package/api/src/store/models/payout.ts +2 -2
- package/api/src/store/models/product-vendor.ts +5 -0
- package/api/src/store/models/product.ts +1 -0
- package/blocklet.yml +1 -1
- package/doc/vendor_fulfillment_system.md +38 -38
- package/package.json +5 -5
- package/src/locales/en.tsx +29 -26
- package/src/locales/zh.tsx +5 -2
- package/src/pages/admin/products/vendors/create.tsx +36 -0
package/README.md
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
# Payment Kit
|
|
2
2
|
|
|
3
|
-
The decentralized
|
|
3
|
+
The decentralized Stripe for the Blocklet platform.
|
|
4
4
|
|
|
5
5
|
## Contribution
|
|
6
6
|
|
|
7
7
|
### Development
|
|
8
8
|
|
|
9
|
-
1.
|
|
10
|
-
2.
|
|
11
|
-
3.
|
|
9
|
+
1. Clone the repository
|
|
10
|
+
2. Run `make build`
|
|
11
|
+
3. Run `cd blocklets/core && blocklet dev`
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
#### Troubleshooting
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**Error: "pre-start error component xxx is not running or unreachable"**
|
|
16
16
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
17
|
+
- Create a `.env.local` file in the project root
|
|
18
|
+
- Add `BLOCKLET_DEV_APP_DID="did:abt:your payment kit server did"`
|
|
19
|
+
- Add `BLOCKLET_DEV_MOUNT_POINT="/example"`
|
|
20
|
+
- Copy `.env.local` to the `/core` directory
|
|
21
|
+
- Edit `BLOCKLET_DEV_MOUNT_POINT="/"`
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
**Error: "Insufficient funds to pay for transaction cost from xxx, expected 1.0020909, got 0"**
|
|
24
|
+
|
|
25
|
+
- Copy the `BLOCKLET_DEV_APP_DID`
|
|
26
|
+
- Transfer 2 TBA from your DID Wallet to the copied address
|
|
26
27
|
|
|
27
28
|
### Debug Stripe
|
|
28
29
|
|
|
29
|
-
1. Install and
|
|
30
|
-
2. Start your local
|
|
30
|
+
1. Install and log in following the instructions at: https://stripe.com/docs/stripe-cli
|
|
31
|
+
2. Start your local Payment Kit server and note its port
|
|
31
32
|
3. Run `stripe listen --forward-to http://127.0.0.1:8188/api/integrations/stripe/webhook --log-level=debug --latest`
|
|
32
33
|
|
|
33
34
|
### Test Stripe
|
|
34
35
|
|
|
35
|
-
Invoices for subscriptions are not finalized automatically. You can use
|
|
36
|
+
Invoices for subscriptions are not finalized automatically. You can use the Stripe Postman collection to finalize them and then confirm the payment.
|
|
36
37
|
|
|
37
38
|
### Easter Eggs
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Non-public environment variables used in the code that can change the behavior of Payment Kit:
|
|
40
41
|
|
|
41
|
-
- PAYMENT_CHANGE_LOCKED_PRICE
|
|
42
|
-
- PAYMENT_RELOAD_SUBSCRIPTION_JOBS
|
|
43
|
-
- PAYMENT_BILLING_THRESHOLD
|
|
44
|
-
- PAYMENT_MIN_STAKE_AMOUNT
|
|
45
|
-
- PAYMENT_DAYS_UNTIL_DUE
|
|
46
|
-
- PAYMENT_DAYS_UNTIL_CANCEL
|
|
42
|
+
- `PAYMENT_CHANGE_LOCKED_PRICE`: Allows changing locked price (must be set to "1" to enable)
|
|
43
|
+
- `PAYMENT_RELOAD_SUBSCRIPTION_JOBS`: Reloads subscription jobs on start (must be set to "1" to enable)
|
|
44
|
+
- `PAYMENT_BILLING_THRESHOLD`: Global default billing threshold (must be a number)
|
|
45
|
+
- `PAYMENT_MIN_STAKE_AMOUNT`: Global minimum stake amount limit (must be a number)
|
|
46
|
+
- `PAYMENT_DAYS_UNTIL_DUE`: Global default days until due (must be a number)
|
|
47
|
+
- `PAYMENT_DAYS_UNTIL_CANCEL`: Global default days until cancellation (must be a number)
|
|
@@ -6,7 +6,7 @@ import { handlePaymentIntentEvent } from './payment-intent';
|
|
|
6
6
|
import { handleSetupIntentEvent } from './setup-intent';
|
|
7
7
|
import { handleSubscriptionEvent } from './subscription';
|
|
8
8
|
|
|
9
|
-
export default function handleStripeEvent(event: any, client: Stripe) {
|
|
9
|
+
export default async function handleStripeEvent(event: any, client: Stripe) {
|
|
10
10
|
switch (event.type) {
|
|
11
11
|
case 'payment_intent.canceled':
|
|
12
12
|
case 'payment_intent.created':
|
|
@@ -14,8 +14,16 @@ export default function handleStripeEvent(event: any, client: Stripe) {
|
|
|
14
14
|
case 'payment_intent.payment_failed':
|
|
15
15
|
case 'payment_intent.processing':
|
|
16
16
|
case 'payment_intent.requires_action':
|
|
17
|
-
case 'payment_intent.succeeded':
|
|
17
|
+
case 'payment_intent.succeeded': {
|
|
18
|
+
if (event.data?.object?.id) {
|
|
19
|
+
const record = await client.paymentIntents.retrieve(event.data.object.id);
|
|
20
|
+
event.data.object = {
|
|
21
|
+
...event.data.object,
|
|
22
|
+
...record,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
18
25
|
return handlePaymentIntentEvent(event, client);
|
|
26
|
+
}
|
|
19
27
|
|
|
20
28
|
// case 'setup_intent.created':
|
|
21
29
|
case 'setup_intent.canceled':
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -67,7 +67,7 @@ export async function getOneTimeProductInfo(invoiceId: string, paymentCurrency:
|
|
|
67
67
|
include: [{ model: InvoiceItem, as: 'lines' }],
|
|
68
68
|
});
|
|
69
69
|
if (!doc) {
|
|
70
|
-
throw new Error(`Invoice not found
|
|
70
|
+
throw new Error(`Invoice not found: ${invoiceId}`);
|
|
71
71
|
}
|
|
72
72
|
const json = doc.toJSON();
|
|
73
73
|
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
@@ -733,19 +733,19 @@ export async function ensureOverdraftProtectionInvoiceAndItems({
|
|
|
733
733
|
const invoicePrice = price?.currency_options?.find((x: any) => x.currency_id === paymentIntent?.currency_id);
|
|
734
734
|
|
|
735
735
|
if (!subscription.overdraft_protection?.enabled) {
|
|
736
|
-
throw new Error('
|
|
736
|
+
throw new Error('Overdraft protection invoice creation skipped: overdraft protection is not enabled');
|
|
737
737
|
}
|
|
738
738
|
|
|
739
739
|
if (!invoicePrice) {
|
|
740
|
-
throw new Error('
|
|
740
|
+
throw new Error('Overdraft protection invoice price not found');
|
|
741
741
|
}
|
|
742
742
|
const currency = await PaymentCurrency.findByPk(invoicePrice.currency_id);
|
|
743
743
|
if (!currency) {
|
|
744
|
-
throw new Error('
|
|
744
|
+
throw new Error('Overdraft protection invoice currency not found');
|
|
745
745
|
}
|
|
746
746
|
const paymentMethod = await PaymentMethod.findByPk(currency?.payment_method_id);
|
|
747
747
|
if (!paymentMethod) {
|
|
748
|
-
throw new Error('
|
|
748
|
+
throw new Error('Overdraft protection invoice payment method not found');
|
|
749
749
|
}
|
|
750
750
|
|
|
751
751
|
if (paymentMethod.type !== 'arcblock') {
|
|
@@ -754,7 +754,7 @@ export async function ensureOverdraftProtectionInvoiceAndItems({
|
|
|
754
754
|
|
|
755
755
|
const { unused } = await isSubscriptionOverdraftProtectionEnabled(subscription, paymentIntent?.currency_id);
|
|
756
756
|
if (new BN(unused).lt(new BN(invoicePrice.unit_amount))) {
|
|
757
|
-
throw new Error('
|
|
757
|
+
throw new Error('Overdraft protection invoice creation skipped: insufficient overdraft protection funds');
|
|
758
758
|
}
|
|
759
759
|
|
|
760
760
|
const result = await createInvoiceWithItems({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VendorAuth } from '@blocklet/payment-vendor';
|
|
2
2
|
|
|
3
|
+
import { joinURL } from 'ufo';
|
|
3
4
|
import { ProductVendor } from '../../../store/models';
|
|
4
5
|
import logger from '../../logger';
|
|
5
6
|
import { api } from '../../util';
|
|
@@ -53,11 +54,14 @@ export class LauncherAdapter implements VendorAdapter {
|
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
const { headers, body } = VendorAuth.signRequestWithHeaders(orderData);
|
|
56
|
-
const response = await fetch(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
const response = await fetch(
|
|
58
|
+
joinURL(launcherApiUrl, vendorConfig.metadata?.mountPoint || '', '/api/vendor/deliveries'),
|
|
59
|
+
{
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers,
|
|
62
|
+
body,
|
|
63
|
+
}
|
|
64
|
+
);
|
|
61
65
|
|
|
62
66
|
if (!response.ok) {
|
|
63
67
|
const errorBody = await response.text();
|
|
@@ -125,11 +129,14 @@ export class LauncherAdapter implements VendorAdapter {
|
|
|
125
129
|
const launcherApiUrl = vendorConfig.app_url;
|
|
126
130
|
const { headers, body } = VendorAuth.signRequestWithHeaders(params);
|
|
127
131
|
|
|
128
|
-
const response = await fetch(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
const response = await fetch(
|
|
133
|
+
joinURL(launcherApiUrl, vendorConfig.metadata?.mountPoint || '', '/api/vendor/return'),
|
|
134
|
+
{
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers,
|
|
137
|
+
body,
|
|
138
|
+
}
|
|
139
|
+
);
|
|
133
140
|
|
|
134
141
|
if (!response.ok) {
|
|
135
142
|
const errorBody = await response.text();
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { BN } from '@ocap/util';
|
|
2
|
+
import { Customer, PaymentMethod } from '../../store/models';
|
|
2
3
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
3
|
-
import { Product } from '../../store/models/product';
|
|
4
|
-
import { ProductVendor } from '../../store/models/product-vendor';
|
|
5
|
-
import { Payout } from '../../store/models/payout';
|
|
6
4
|
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
5
|
+
import { Payout } from '../../store/models/payout';
|
|
6
|
+
import { ProductVendor } from '../../store/models/product-vendor';
|
|
7
|
+
import env from '../env';
|
|
10
8
|
import logger from '../logger';
|
|
9
|
+
import { calculateVendorCommission, VendorAdapterFactory } from './adapters/factory';
|
|
11
10
|
|
|
12
11
|
const DEFAULT_COMMISSION_RATE = 20;
|
|
13
12
|
|
|
@@ -80,11 +79,11 @@ export class VendorFulfillmentService {
|
|
|
80
79
|
amount: orderInfo.amount_total,
|
|
81
80
|
currency: orderInfo.currency_id,
|
|
82
81
|
|
|
83
|
-
description:
|
|
82
|
+
description: `This is an app launched by ${env.appName || 'Third Party'}`,
|
|
84
83
|
userInfo: {
|
|
85
84
|
userDid: orderInfo.customer_did!,
|
|
86
85
|
email: userEmail,
|
|
87
|
-
description:
|
|
86
|
+
description: `This is an app launched by ${env.appName || 'Third Party'}`,
|
|
88
87
|
},
|
|
89
88
|
deliveryParams: {
|
|
90
89
|
blockletMetaUrl: vendor.metadata?.blockletMetaUrl,
|
|
@@ -125,117 +124,6 @@ export class VendorFulfillmentService {
|
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
// Calculate commission data (no fulfillment, only calculate commission)
|
|
129
|
-
private static async calculateCommissionData(checkoutSessionId: string): Promise<VendorFulfillmentResult[]> {
|
|
130
|
-
try {
|
|
131
|
-
logger.info('Calculating commission data for checkout session', { checkoutSessionId });
|
|
132
|
-
|
|
133
|
-
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
134
|
-
if (!checkoutSession) {
|
|
135
|
-
throw new Error(`CheckoutSession not found: ${checkoutSessionId}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const priceIds = checkoutSession.line_items.map((item: any) => item.price_id).filter(Boolean);
|
|
139
|
-
|
|
140
|
-
if (priceIds.length === 0) {
|
|
141
|
-
throw new Error(`No price IDs found in checkout session line items: ${checkoutSessionId}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Find corresponding product_ids through price_ids
|
|
145
|
-
const prices = await Price.findAll({
|
|
146
|
-
where: { id: priceIds },
|
|
147
|
-
attributes: ['id', 'product_id'],
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// Create a map of price_id to line_item for amount calculation
|
|
151
|
-
const priceToLineItemMap = new Map();
|
|
152
|
-
checkoutSession.line_items.forEach((item: any) => {
|
|
153
|
-
if (item.price_id) {
|
|
154
|
-
priceToLineItemMap.set(item.price_id, item);
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
const productIds = prices.map((price: any) => price.product_id).filter(Boolean);
|
|
159
|
-
|
|
160
|
-
if (productIds.length === 0) {
|
|
161
|
-
throw new Error(`No product IDs found from prices: ${checkoutSessionId}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Get all products with vendor configurations
|
|
165
|
-
const products = await Product.findAll({
|
|
166
|
-
where: { id: productIds },
|
|
167
|
-
attributes: ['id', 'vendor_config'],
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const commissionResults: VendorFulfillmentResult[] = [];
|
|
171
|
-
|
|
172
|
-
// Process each product separately
|
|
173
|
-
for (const product of products) {
|
|
174
|
-
if (!product.vendor_config || product.vendor_config.length === 0) {
|
|
175
|
-
logger.debug('No vendor configuration found for product', {
|
|
176
|
-
productId: product.id,
|
|
177
|
-
checkoutSessionId,
|
|
178
|
-
});
|
|
179
|
-
// eslint-disable-next-line no-continue
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Find all prices for this product
|
|
184
|
-
const productPrices = prices.filter((price: any) => price.product_id === product.id);
|
|
185
|
-
|
|
186
|
-
// Calculate total amount for this product across all line items
|
|
187
|
-
let productTotalAmount = '0';
|
|
188
|
-
for (const price of productPrices) {
|
|
189
|
-
const lineItem = priceToLineItemMap.get(price.id);
|
|
190
|
-
if (lineItem && lineItem.amount_total) {
|
|
191
|
-
productTotalAmount = new BN(productTotalAmount).add(new BN(lineItem.amount_total || '0')).toString();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Calculate commission for each vendor of this product
|
|
196
|
-
for (const vendorConfig of product.vendor_config) {
|
|
197
|
-
const commissionAmount = calculateVendorCommission(
|
|
198
|
-
productTotalAmount, // Use product-specific amount instead of total order amount
|
|
199
|
-
vendorConfig.commission_rate || 0,
|
|
200
|
-
vendorConfig.commission_type || 'percentage',
|
|
201
|
-
vendorConfig.amount
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
commissionResults.push({
|
|
205
|
-
vendorId: vendorConfig.vendor_key,
|
|
206
|
-
vendorKey: vendorConfig.vendor_key,
|
|
207
|
-
orderId: `commission_${checkoutSessionId}_${product.id}_${vendorConfig.vendor_id}`,
|
|
208
|
-
status: 'completed',
|
|
209
|
-
commissionAmount,
|
|
210
|
-
commissionType: vendorConfig.commission_type || 'percentage',
|
|
211
|
-
commissionRate: vendorConfig.commission_rate || 0,
|
|
212
|
-
productId: product.id, // Add product ID for tracking
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (commissionResults.length === 0) {
|
|
218
|
-
logger.warn('No vendor configurations found for any products', {
|
|
219
|
-
productIds,
|
|
220
|
-
checkoutSessionId,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
logger.info('Commission data calculated', {
|
|
225
|
-
checkoutSessionId,
|
|
226
|
-
commissionCount: commissionResults.length,
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
return commissionResults;
|
|
230
|
-
} catch (error: any) {
|
|
231
|
-
logger.error('Failed to calculate commission data', {
|
|
232
|
-
checkoutSessionId,
|
|
233
|
-
error: error.message,
|
|
234
|
-
});
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
127
|
static async createVendorPayouts(
|
|
240
128
|
checkoutSessionId: string,
|
|
241
129
|
fulfillmentResults?: VendorFulfillmentResult[]
|
|
@@ -257,13 +145,19 @@ export class VendorFulfillmentService {
|
|
|
257
145
|
// If fulfillmentResults not provided, calculate commission info
|
|
258
146
|
let commissionData = fulfillmentResults;
|
|
259
147
|
if (!commissionData) {
|
|
260
|
-
commissionData =
|
|
148
|
+
commissionData = checkoutSession.vendor_info?.map((vendorInfo: any) => ({
|
|
149
|
+
vendorId: vendorInfo.vendor_id,
|
|
150
|
+
orderId: vendorInfo.order_id,
|
|
151
|
+
status: vendorInfo.status,
|
|
152
|
+
commissionAmount: vendorInfo.commissionAmount,
|
|
153
|
+
})) as VendorFulfillmentResult[];
|
|
261
154
|
}
|
|
262
155
|
|
|
263
156
|
const payoutPromises = commissionData
|
|
264
157
|
.filter((result) => result.status !== 'failed' && new BN(result.commissionAmount).gt(new BN('0')))
|
|
265
158
|
.map(async (result) => {
|
|
266
159
|
const vendor = await ProductVendor.findByPk(result.vendorId);
|
|
160
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentMethodId);
|
|
267
161
|
|
|
268
162
|
const appPid = vendor?.app_pid;
|
|
269
163
|
const destination = appPid || vendor?.metadata?.wallet_address || vendor?.metadata?.destination || '';
|
|
@@ -278,7 +172,7 @@ export class VendorFulfillmentService {
|
|
|
278
172
|
customer_id: checkoutSession.customer_id || '',
|
|
279
173
|
payment_intent_id: checkoutSession.payment_intent_id || '',
|
|
280
174
|
payment_method_id: paymentMethodId,
|
|
281
|
-
status: 'pending',
|
|
175
|
+
status: paymentMethod?.type === 'stripe' ? 'deferred' : 'pending',
|
|
282
176
|
attempt_count: 0,
|
|
283
177
|
attempted: false,
|
|
284
178
|
vendor_info: {
|
package/api/src/locales/en.ts
CHANGED
|
@@ -54,45 +54,45 @@ export default flat({
|
|
|
54
54
|
|
|
55
55
|
billingDiscrepancy: {
|
|
56
56
|
title: '{productName} billing discrepancy',
|
|
57
|
-
body: '
|
|
57
|
+
body: 'A billing discrepancy has been detected for {productName}. Please review your billing details.',
|
|
58
58
|
},
|
|
59
59
|
|
|
60
60
|
sendTo: 'Sent to',
|
|
61
61
|
mintNFT: {
|
|
62
62
|
title: '{collection} NFT minted',
|
|
63
|
-
message: 'A new {collection} NFT
|
|
63
|
+
message: 'A new {collection} NFT has been minted and sent to your wallet. Please check your wallet to view it.',
|
|
64
64
|
},
|
|
65
65
|
|
|
66
66
|
usageReportEmpty: {
|
|
67
67
|
title: 'No usage report for {productName}',
|
|
68
|
-
body: 'No usage report for {productName}
|
|
68
|
+
body: 'No usage report has been detected for {productName}. Please verify your usage reporting configuration.',
|
|
69
69
|
},
|
|
70
70
|
|
|
71
71
|
meteringSubscriptionDetection: {
|
|
72
72
|
title: '[{appName}] Metering subscription detection',
|
|
73
|
-
body: 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned
|
|
73
|
+
body: 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned.\nOf these, {normalCount} were normal, while {abnormalCount} had anomalies, including {unreportedCount} unreported subscriptions and {discrepantCount} with billing discrepancies.\n\nAbnormal subscriptions:',
|
|
74
74
|
healthyBody:
|
|
75
|
-
'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned, and all subscriptions were
|
|
75
|
+
'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned, and all subscriptions were functioning normally.',
|
|
76
76
|
view: 'Manage subscriptions',
|
|
77
77
|
},
|
|
78
78
|
|
|
79
79
|
subscriptionTrialStart: {
|
|
80
80
|
title: 'Welcome to the start of your {productName} trial',
|
|
81
|
-
body: 'Congratulations on your {productName} trial!
|
|
81
|
+
body: 'Congratulations on starting your {productName} trial! Your trial period is {trialDuration} and will end on {subscriptionTrialEnd}. Enjoy exploring {productName}!',
|
|
82
82
|
},
|
|
83
83
|
|
|
84
84
|
subscriptionTrialWillEnd: {
|
|
85
85
|
title: 'The {productName} trial will end soon',
|
|
86
86
|
body: 'Your trial for {productName} will end in {willRenewDuration}. Please ensure your account balance is sufficient for automatic billing after the trial ends. Thank you for your support and trust!',
|
|
87
87
|
unableToPayBody:
|
|
88
|
-
'Your trial for {productName} will end in {willRenewDuration}.Your current balance is {balance}, which is less than {price}
|
|
88
|
+
'Your trial for {productName} will end in {willRenewDuration}. Your current balance is {balance}, which is less than {price}. Please ensure your balance is sufficient for automatic billing. Thank you for your support and trust!',
|
|
89
89
|
unableToPayReason:
|
|
90
|
-
'The estimated payment amount is {price}, but
|
|
90
|
+
'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
|
|
91
91
|
},
|
|
92
92
|
|
|
93
93
|
subscriptionSucceed: {
|
|
94
94
|
title: "Congratulations! You've successfully subscribed to {productName}",
|
|
95
|
-
body: 'Thank you for successfully subscribing to {productName} on {at}. We
|
|
95
|
+
body: 'Thank you for successfully subscribing to {productName} on {at}. We are happy to provide you with excellent service, and we wish you a pleasant experience.',
|
|
96
96
|
},
|
|
97
97
|
|
|
98
98
|
oneTimePaymentSucceeded: {
|
|
@@ -102,7 +102,7 @@ export default flat({
|
|
|
102
102
|
|
|
103
103
|
subscriptionUpgraded: {
|
|
104
104
|
title: 'Congratulations! Your subscription plan for {productName} has been successfully upgraded',
|
|
105
|
-
body: 'Your subscription plan for {productName} has been successfully upgraded
|
|
105
|
+
body: 'Your subscription plan for {productName} has been successfully upgraded on {at}. Thank you for your support, and we wish you a pleasant experience!',
|
|
106
106
|
},
|
|
107
107
|
|
|
108
108
|
subscriptionWillRenew: {
|
|
@@ -111,53 +111,53 @@ export default flat({
|
|
|
111
111
|
unableToPayBody:
|
|
112
112
|
'Your subscription to {productName} is scheduled for automatic payment on {at}({willRenewDuration} later). If you have any questions or need assistance, please feel free to contact us.',
|
|
113
113
|
unableToPayReason:
|
|
114
|
-
'The estimated payment amount is {price}, but
|
|
114
|
+
'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
|
|
115
115
|
renewAmount: 'Payment amount',
|
|
116
116
|
estimatedAmountNote: 'Estimate {amount}, billed based on final usage',
|
|
117
117
|
},
|
|
118
118
|
|
|
119
119
|
subscriptionRenewed: {
|
|
120
120
|
title: '{productName} payment successful',
|
|
121
|
-
body: 'Payment for your
|
|
122
|
-
noExpenseIncurred: 'No expenses incurred during the service ',
|
|
121
|
+
body: 'Payment for your {productName} subscription was successfully collected on {at}. Thanks for your continued support and trust. We wish you a pleasant experience using this service!',
|
|
122
|
+
noExpenseIncurred: 'No expenses incurred during the service period',
|
|
123
123
|
},
|
|
124
124
|
|
|
125
125
|
subscriptionRenewFailed: {
|
|
126
126
|
title: '{productName} automatic payment failed',
|
|
127
|
-
body: 'We are sorry to inform you that your {productName}
|
|
127
|
+
body: 'We are sorry to inform you that your {productName} automatic payment failed on {at}. If you have any questions, please contact us promptly. Thank you!',
|
|
128
128
|
reason: {
|
|
129
|
-
noDidWallet: 'You have not
|
|
130
|
-
noDelegation: 'Your DID Wallet has not been authorized
|
|
129
|
+
noDidWallet: 'You have not connected a DID Wallet. Please connect your DID Wallet to ensure sufficient balance',
|
|
130
|
+
noDelegation: 'Your DID Wallet has not been authorized. Please update authorization',
|
|
131
131
|
noTransferPermission:
|
|
132
|
-
'Your DID Wallet has not granted transfer permission to the application
|
|
132
|
+
'Your DID Wallet has not granted transfer permission to the application. Please update authorization',
|
|
133
133
|
noTokenPermission:
|
|
134
|
-
'Your DID Wallet has not granted token transfer permission to the application
|
|
134
|
+
'Your DID Wallet has not granted token transfer permission to the application. Please update authorization',
|
|
135
135
|
noTransferTo:
|
|
136
|
-
'Your DID Wallet has not granted
|
|
137
|
-
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit
|
|
138
|
-
noToken: 'Your account has no tokens
|
|
139
|
-
noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}
|
|
140
|
-
noSupported: '
|
|
136
|
+
'Your DID Wallet has not granted automatic payment permission to the application. Please update authorization',
|
|
137
|
+
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit. Please update authorization',
|
|
138
|
+
noToken: 'Your account has no tokens. Please add funds',
|
|
139
|
+
noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
|
|
140
|
+
noSupported: 'Automatic payment with tokens is not supported. Please check your package',
|
|
141
141
|
txSendFailed: 'Failed to send automatic payment transaction',
|
|
142
142
|
},
|
|
143
143
|
},
|
|
144
144
|
|
|
145
145
|
autoRechargeFailed: {
|
|
146
146
|
title: 'Auto Top-Up payment failed',
|
|
147
|
-
body: 'We are sorry to inform you that your {creditCurrencyName} auto top-up
|
|
147
|
+
body: 'We are sorry to inform you that your {creditCurrencyName} auto top-up automatic payment failed on {at}. If you have any questions, please contact us promptly. Thank you!',
|
|
148
148
|
reason: {
|
|
149
|
-
noDidWallet: 'You have not
|
|
150
|
-
noDelegation: 'Your DID Wallet has not been authorized
|
|
149
|
+
noDidWallet: 'You have not connected a DID Wallet. Please connect your DID Wallet to ensure sufficient balance',
|
|
150
|
+
noDelegation: 'Your DID Wallet has not been authorized. Please update authorization',
|
|
151
151
|
noTransferPermission:
|
|
152
|
-
'Your DID Wallet has not granted transfer permission to the application
|
|
152
|
+
'Your DID Wallet has not granted transfer permission to the application. Please update authorization',
|
|
153
153
|
noTokenPermission:
|
|
154
|
-
'Your DID Wallet has not granted token transfer permission to the application
|
|
154
|
+
'Your DID Wallet has not granted token transfer permission to the application. Please update authorization',
|
|
155
155
|
noTransferTo:
|
|
156
|
-
'Your DID Wallet has not granted
|
|
157
|
-
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit
|
|
158
|
-
noToken: 'Your account has no tokens
|
|
159
|
-
noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}
|
|
160
|
-
noSupported: '
|
|
156
|
+
'Your DID Wallet has not granted automatic payment permission to the application. Please update authorization',
|
|
157
|
+
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit. Please update authorization',
|
|
158
|
+
noToken: 'Your account has no tokens. Please add funds',
|
|
159
|
+
noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
|
|
160
|
+
noSupported: 'Automatic payment with tokens is not supported. Please check your package',
|
|
161
161
|
txSendFailed: 'Failed to send automatic payment transaction',
|
|
162
162
|
},
|
|
163
163
|
},
|
|
@@ -187,7 +187,7 @@ export default flat({
|
|
|
187
187
|
|
|
188
188
|
customerRewardSucceeded: {
|
|
189
189
|
title: 'Thanks for your reward of {amount}',
|
|
190
|
-
body: '
|
|
190
|
+
body: 'Thank you for your reward on {at}. The reward amount is {amount}. Your support is our driving force. Thank you for your generous support!',
|
|
191
191
|
received: '{address} has received {amount}',
|
|
192
192
|
viewDetail: 'View Reference',
|
|
193
193
|
},
|
|
@@ -204,14 +204,14 @@ export default flat({
|
|
|
204
204
|
},
|
|
205
205
|
sender: 'Payer',
|
|
206
206
|
viewDetail: 'View Details',
|
|
207
|
-
|
|
207
|
+
sent: '{address} has sent {amount}',
|
|
208
208
|
},
|
|
209
209
|
subscriptWillCanceled: {
|
|
210
|
-
title: '{productName} subscription is about to be cancelled
|
|
210
|
+
title: '{productName} subscription is about to be cancelled',
|
|
211
211
|
pastDue:
|
|
212
|
-
'Your
|
|
212
|
+
'Your {productName} subscription will be automatically cancelled by the system after {at} ({willCancelDuration} later) due to repeated automatic payment failures. Please resolve the automatic payment issue manually to avoid service interruption. If you have any questions, please feel free to contact us.',
|
|
213
213
|
body: 'Your subscription to {productName} will be automatically canceled on {at} ({willCancelDuration} later). If you have any questions, please feel free to contact us.',
|
|
214
|
-
renewAmount: '
|
|
214
|
+
renewAmount: 'Deduction amount',
|
|
215
215
|
cancelReason: 'Cancel reason',
|
|
216
216
|
revokeStake: 'Revoke stake',
|
|
217
217
|
adminCanceled: 'Admin canceled',
|
|
@@ -373,11 +373,7 @@ export const handlePaymentSucceed = async (
|
|
|
373
373
|
);
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
// Trigger vendor commission queue
|
|
377
|
-
events.emit('vendor.commission.queued', `vendor-commission-${paymentIntent.id}`, {
|
|
378
|
-
paymentIntentId: paymentIntent.id,
|
|
379
|
-
retryOnError: true,
|
|
380
|
-
});
|
|
376
|
+
// Trigger vendor commission queue by invoice.paid
|
|
381
377
|
|
|
382
378
|
let invoice;
|
|
383
379
|
if (paymentIntent.invoice_id) {
|
|
@@ -167,25 +167,27 @@ export const startVendorCommissionQueue = async () => {
|
|
|
167
167
|
});
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
-
events.on('
|
|
170
|
+
events.on('invoice.paid', async (invoice) => {
|
|
171
171
|
try {
|
|
172
|
-
const {
|
|
173
|
-
const exist = await vendorCommissionQueue.get(id);
|
|
172
|
+
const paymentIntent = await PaymentIntent.findOne({ where: { invoice_id: invoice.id } });
|
|
174
173
|
|
|
175
|
-
if (!
|
|
176
|
-
logger.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
174
|
+
if (!paymentIntent) {
|
|
175
|
+
logger.warn('PaymentIntent not found', { id: invoice.id });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const id = `vendor-commission-${paymentIntent.id}`;
|
|
180
|
+
const exist = await vendorCommissionQueue.get(id);
|
|
181
|
+
if (exist) {
|
|
183
182
|
logger.info('Vendor commission job already exists, skipping', { id });
|
|
183
|
+
return;
|
|
184
184
|
}
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
|
|
186
|
+
vendorCommissionQueue.push({
|
|
187
187
|
id,
|
|
188
|
-
|
|
188
|
+
job: { paymentIntentId: paymentIntent.id, retryOnError: true },
|
|
189
189
|
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logger.error('Failed to trigger vendor commission queue', { invoiceId: invoice.id, error });
|
|
190
192
|
}
|
|
191
193
|
});
|
|
@@ -273,14 +273,14 @@ async function updateSingleVendorInfo(
|
|
|
273
273
|
vendor_id: current[index]?.vendor_id || vendorId,
|
|
274
274
|
order_id: update.order_id || current[index]?.order_id || '',
|
|
275
275
|
status: update.status || current[index]?.status || 'pending',
|
|
276
|
-
amount: update.
|
|
276
|
+
amount: update.commissionAmount || current[index]?.amount || '0',
|
|
277
277
|
} as VendorInfo;
|
|
278
278
|
} else {
|
|
279
279
|
current.push({
|
|
280
280
|
vendor_id: vendorId,
|
|
281
281
|
order_id: update.order_id || '',
|
|
282
282
|
status: update.status || 'pending',
|
|
283
|
-
amount: update.
|
|
283
|
+
amount: update.commissionAmount || '0',
|
|
284
284
|
...update,
|
|
285
285
|
} as VendorInfo);
|
|
286
286
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { joinURL } from 'ufo';
|
|
2
|
+
import { VendorAuth } from '@blocklet/payment-vendor';
|
|
2
3
|
import createQueue from '../../libs/queue';
|
|
3
|
-
import { getBlockletJson } from '../../libs/util';
|
|
4
4
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
5
5
|
import { ProductVendor } from '../../store/models';
|
|
6
6
|
import { fulfillmentCoordinatorQueue } from './fulfillment-coordinator';
|
|
@@ -99,12 +99,17 @@ export const handleVendorStatusCheck = async (job: VendorStatusCheckJob) => {
|
|
|
99
99
|
logger.info('found vendor url', { url, productVendor });
|
|
100
100
|
|
|
101
101
|
if (url) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
const serverStatusUrl = joinURL(
|
|
103
|
+
url,
|
|
104
|
+
productVendor?.metadata?.mountPoint,
|
|
105
|
+
'/api/vendor/status',
|
|
106
|
+
vendor.order_id
|
|
107
|
+
);
|
|
108
|
+
const { headers } = VendorAuth.signRequestWithHeaders({});
|
|
109
|
+
|
|
110
|
+
const result = await fetch(serverStatusUrl, { headers });
|
|
107
111
|
const data = await result.json();
|
|
112
|
+
|
|
108
113
|
vendor.app_url = data?.appUrl || '';
|
|
109
114
|
}
|
|
110
115
|
}
|
package/api/src/routes/vendor.ts
CHANGED
|
@@ -22,6 +22,9 @@ const createVendorSchema = Joi.object({
|
|
|
22
22
|
app_url: Joi.string().uri().max(512).required(),
|
|
23
23
|
app_pid: Joi.string().max(255).allow('').optional(),
|
|
24
24
|
app_logo: Joi.string().max(512).allow('').optional(),
|
|
25
|
+
vendor_did: Joi.string()
|
|
26
|
+
.pattern(/^(did:abt:)?[1-9A-HJ-NP-Za-km-z]{37}$/)
|
|
27
|
+
.required(),
|
|
25
28
|
status: Joi.string().valid('active', 'inactive').default('active'),
|
|
26
29
|
metadata: MetadataSchema,
|
|
27
30
|
}).unknown(false);
|
|
@@ -33,6 +36,9 @@ const updateVendorSchema = Joi.object({
|
|
|
33
36
|
app_url: Joi.string().uri().max(512).optional(),
|
|
34
37
|
app_pid: Joi.string().max(255).allow('').optional(),
|
|
35
38
|
app_logo: Joi.string().max(512).allow('').optional(),
|
|
39
|
+
vendor_did: Joi.string()
|
|
40
|
+
.pattern(/^(did:abt:)?[1-9A-HJ-NP-Za-km-z]{37}$/)
|
|
41
|
+
.required(),
|
|
36
42
|
status: Joi.string().valid('active', 'inactive').optional(),
|
|
37
43
|
metadata: MetadataSchema,
|
|
38
44
|
}).unknown(true);
|
|
@@ -139,6 +145,7 @@ async function createVendor(req: any, res: any) {
|
|
|
139
145
|
name,
|
|
140
146
|
description,
|
|
141
147
|
app_url: appUrl,
|
|
148
|
+
vendor_did: vendorDid,
|
|
142
149
|
metadata,
|
|
143
150
|
app_pid: appPid,
|
|
144
151
|
app_logo: appLogo,
|
|
@@ -158,6 +165,7 @@ async function createVendor(req: any, res: any) {
|
|
|
158
165
|
name,
|
|
159
166
|
description,
|
|
160
167
|
app_url: appUrl,
|
|
168
|
+
vendor_did: vendorDid?.replace('did:abt:', '').trim(),
|
|
161
169
|
status: status || 'active',
|
|
162
170
|
app_pid: appPid,
|
|
163
171
|
app_logo: appLogo,
|
|
@@ -195,6 +203,7 @@ async function updateVendor(req: any, res: any) {
|
|
|
195
203
|
name,
|
|
196
204
|
description,
|
|
197
205
|
app_url: appUrl,
|
|
206
|
+
vendor_did: vendorDid,
|
|
198
207
|
status,
|
|
199
208
|
metadata,
|
|
200
209
|
app_pid: appPid,
|
|
@@ -214,6 +223,7 @@ async function updateVendor(req: any, res: any) {
|
|
|
214
223
|
name,
|
|
215
224
|
description,
|
|
216
225
|
app_url: appUrl,
|
|
226
|
+
vendor_did: vendorDid,
|
|
217
227
|
status,
|
|
218
228
|
metadata,
|
|
219
229
|
app_pid: appPid,
|
|
@@ -276,7 +286,15 @@ async function getVendorStatusByVendorId(vendorId: string, orderId: string, isDe
|
|
|
276
286
|
}
|
|
277
287
|
|
|
278
288
|
const vendor = await ProductVendor.findByPk(vendorId);
|
|
279
|
-
const url = vendor?.app_url
|
|
289
|
+
const url = vendor?.app_url
|
|
290
|
+
? joinURL(
|
|
291
|
+
vendor.app_url,
|
|
292
|
+
vendor.metadata?.mountPoint || '',
|
|
293
|
+
'/api/vendor/',
|
|
294
|
+
isDetail ? 'orders' : 'status',
|
|
295
|
+
orderId
|
|
296
|
+
)
|
|
297
|
+
: null;
|
|
280
298
|
|
|
281
299
|
const { headers } = VendorAuth.signRequestWithHeaders({});
|
|
282
300
|
const name = vendor?.name;
|
|
@@ -427,6 +445,7 @@ async function redirectToVendor(req: any, res: any) {
|
|
|
427
445
|
|
|
428
446
|
const router = Router();
|
|
429
447
|
|
|
448
|
+
// FIXME: Authentication not yet added, awaiting implementation @Pengfei
|
|
430
449
|
router.get('/order/:sessionId/status', validateParams(sessionIdParamSchema), getVendorFulfillmentStatus);
|
|
431
450
|
router.get('/order/:sessionId/detail', validateParams(sessionIdParamSchema), getVendorFulfillmentDetail);
|
|
432
451
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { safeApplyColumnChanges, type Migration } from '../migrate';
|
|
2
|
+
|
|
3
|
+
export const up: Migration = async ({ context }) => {
|
|
4
|
+
// Add vendor_did column to product_vendors table
|
|
5
|
+
await safeApplyColumnChanges(context, {
|
|
6
|
+
product_vendors: [
|
|
7
|
+
{
|
|
8
|
+
name: 'vendor_did',
|
|
9
|
+
field: {
|
|
10
|
+
type: 'STRING',
|
|
11
|
+
allowNull: true,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const down: Migration = async ({ context }) => {
|
|
19
|
+
await context.removeColumn('product_vendors', 'vendor_did');
|
|
20
|
+
};
|
|
@@ -38,7 +38,7 @@ export class Payout extends Model<InferAttributes<Payout>, InferCreationAttribut
|
|
|
38
38
|
commission_amount: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
declare status: LiteralUnion<'pending' | 'paid' | 'failed' | 'canceled' | 'in_transit', string>;
|
|
41
|
+
declare status: LiteralUnion<'pending' | 'paid' | 'failed' | 'canceled' | 'in_transit' | 'deferred', string>;
|
|
42
42
|
|
|
43
43
|
// retry logic
|
|
44
44
|
declare failure_message?: string;
|
|
@@ -118,7 +118,7 @@ export class Payout extends Model<InferAttributes<Payout>, InferCreationAttribut
|
|
|
118
118
|
allowNull: true,
|
|
119
119
|
},
|
|
120
120
|
status: {
|
|
121
|
-
type: DataTypes.ENUM('paid', 'pending', 'failed', 'canceled', 'in_transit'),
|
|
121
|
+
type: DataTypes.ENUM('paid', 'pending', 'failed', 'canceled', 'in_transit', 'deferred'),
|
|
122
122
|
allowNull: false,
|
|
123
123
|
},
|
|
124
124
|
failure_message: {
|
|
@@ -15,6 +15,7 @@ export class ProductVendor extends Model<InferAttributes<ProductVendor>, InferCr
|
|
|
15
15
|
declare app_url: string;
|
|
16
16
|
declare app_pid?: string;
|
|
17
17
|
declare app_logo?: string;
|
|
18
|
+
declare vendor_did?: string;
|
|
18
19
|
|
|
19
20
|
declare status: 'active' | 'inactive';
|
|
20
21
|
declare metadata: Record<string, any>;
|
|
@@ -60,6 +61,10 @@ export class ProductVendor extends Model<InferAttributes<ProductVendor>, InferCr
|
|
|
60
61
|
type: DataTypes.STRING(512),
|
|
61
62
|
allowNull: true,
|
|
62
63
|
},
|
|
64
|
+
vendor_did: {
|
|
65
|
+
type: DataTypes.STRING(255),
|
|
66
|
+
allowNull: true,
|
|
67
|
+
},
|
|
63
68
|
status: {
|
|
64
69
|
type: DataTypes.ENUM('active', 'inactive'),
|
|
65
70
|
allowNull: false,
|
package/blocklet.yml
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Multi-Vendor Fulfillment System Complete Implementation
|
|
2
2
|
|
|
3
|
-
## 📋
|
|
3
|
+
## 📋 Project Overview
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This document summarizes the complete implementation of the multi-vendor fulfillment system in Payment Kit, including architecture design, process logic, technical implementation, and problem-solving solutions.
|
|
6
6
|
|
|
7
|
-
## 🎯
|
|
7
|
+
## 🎯 System Goals
|
|
8
8
|
|
|
9
|
-
###
|
|
9
|
+
### Core Requirements
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
-
6.
|
|
11
|
+
1. **Multi-vendor Support**: A single order can contain products from multiple vendors
|
|
12
|
+
2. **Asynchronous Fulfillment**: Different vendors can fulfill orders independently and in parallel
|
|
13
|
+
3. **State Coordination**: Unified management of fulfillment status across all vendors
|
|
14
|
+
4. **Fault Tolerance**: Handle fulfillment failures, retries, timeouts, and other exceptional situations
|
|
15
|
+
5. **Commission Processing**: Automatic commission distribution after all vendors complete fulfillment
|
|
16
|
+
6. **Refund Protection**: Full refund if any vendor fails, ensuring user rights
|
|
17
17
|
|
|
18
|
-
###
|
|
18
|
+
### Business Rules
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
20
|
+
- **All Success**: All vendors fulfill successfully → Process commission distribution
|
|
21
|
+
- **Any Failure**: If any vendor fails, times out, or exceeds retry limit → **Initiate return requests for successful vendors** → Full refund to user
|
|
22
|
+
- **Timeout Period**: 5 minutes of no response is considered timeout
|
|
23
|
+
- **Retry Limit**: Maximum 3 retries
|
|
24
|
+
- **Return Mechanism**: Initiate return requests for vendors that have completed fulfillment
|
|
25
25
|
|
|
26
|
-
## 🏗️
|
|
26
|
+
## 🏗️ System Architecture
|
|
27
27
|
|
|
28
|
-
###
|
|
28
|
+
### Architecture Pattern: Coordinator Pattern
|
|
29
29
|
|
|
30
|
-
#### 🔄
|
|
30
|
+
#### 🔄 **Core Process Flow (Text Version)**
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
1. Payment Success
|
|
34
34
|
↓
|
|
35
35
|
2. vendor-commission.ts
|
|
36
|
-
├─
|
|
37
|
-
├─
|
|
38
|
-
└─
|
|
36
|
+
├─ Check vendor configuration
|
|
37
|
+
├─ No vendors → Direct cold wallet transfer ✅
|
|
38
|
+
└─ Has vendors → Call coordinator
|
|
39
39
|
|
|
40
40
|
3. vendor-fulfillment-coordinator.ts
|
|
41
|
-
├─
|
|
42
|
-
├─
|
|
43
|
-
├─
|
|
44
|
-
└─
|
|
41
|
+
├─ Start fulfillment process
|
|
42
|
+
├─ Initialize vendor_info status
|
|
43
|
+
├─ Create fulfillment tasks for each vendor
|
|
44
|
+
└─ Wait passively for notifications
|
|
45
45
|
|
|
46
|
-
4. vendor-fulfillment.ts (
|
|
47
|
-
├─
|
|
48
|
-
├─
|
|
49
|
-
├─
|
|
50
|
-
└─
|
|
46
|
+
4. vendor-fulfillment.ts (parallel execution)
|
|
47
|
+
├─ Vendor A fulfills → Notify coordinator ✅
|
|
48
|
+
├─ Vendor B fulfills → Notify coordinator ✅
|
|
49
|
+
├─ Vendor C fulfills → Notify coordinator ❌
|
|
50
|
+
└─ Retry mechanism (max 3 times)
|
|
51
51
|
|
|
52
52
|
5. vendor-fulfillment-coordinator.ts
|
|
53
|
-
├─
|
|
54
|
-
├─
|
|
55
|
-
├─
|
|
56
|
-
├─
|
|
57
|
-
└─
|
|
53
|
+
├─ Receive all notifications
|
|
54
|
+
├─ Update vendor_info status (transaction + lock)
|
|
55
|
+
├─ Check overall status
|
|
56
|
+
├─ All success → Commission distribution + cold wallet ✅
|
|
57
|
+
└─ Any failure → 🔄 Initiate return requests for successful vendors → Full refund ❌
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
#### 📊
|
|
60
|
+
#### 📊 **Detailed Architecture Diagram**
|
|
61
61
|
|
|
62
62
|
```mermaid
|
|
63
63
|
graph TD
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.11",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"@blocklet/error": "^0.2.5",
|
|
57
57
|
"@blocklet/js-sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
|
|
58
58
|
"@blocklet/logger": "^1.16.52-beta-20250912-112002-e3499e9c",
|
|
59
|
-
"@blocklet/payment-react": "1.20.
|
|
60
|
-
"@blocklet/payment-vendor": "1.20.
|
|
59
|
+
"@blocklet/payment-react": "1.20.11",
|
|
60
|
+
"@blocklet/payment-vendor": "1.20.11",
|
|
61
61
|
"@blocklet/sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
|
|
62
62
|
"@blocklet/ui-react": "^3.1.40",
|
|
63
63
|
"@blocklet/uploader": "^0.2.10",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"devDependencies": {
|
|
127
127
|
"@abtnode/types": "^1.16.52-beta-20250912-112002-e3499e9c",
|
|
128
128
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
129
|
-
"@blocklet/payment-types": "1.20.
|
|
129
|
+
"@blocklet/payment-types": "1.20.11",
|
|
130
130
|
"@types/cookie-parser": "^1.4.9",
|
|
131
131
|
"@types/cors": "^2.8.19",
|
|
132
132
|
"@types/debug": "^4.1.12",
|
|
@@ -173,5 +173,5 @@
|
|
|
173
173
|
"parser": "typescript"
|
|
174
174
|
}
|
|
175
175
|
},
|
|
176
|
-
"gitHead": "
|
|
176
|
+
"gitHead": "b69becabf1721f6238cf24004537bee1e4ade860"
|
|
177
177
|
}
|
package/src/locales/en.tsx
CHANGED
|
@@ -11,11 +11,11 @@ export default flat({
|
|
|
11
11
|
add: 'Add more metadata',
|
|
12
12
|
edit: 'Edit metadata',
|
|
13
13
|
empty: 'No metadata',
|
|
14
|
-
emptyTip: "You haven't added any metadata yet
|
|
14
|
+
emptyTip: "You haven't added any metadata yet. You can add it now",
|
|
15
15
|
formMode: 'Switch to form mode',
|
|
16
16
|
jsonMode: 'Switch to JSON mode',
|
|
17
17
|
jsonPlaceholder: 'Enter JSON data...',
|
|
18
|
-
invalidJson: 'Invalid JSON format
|
|
18
|
+
invalidJson: 'Invalid JSON format. Please check your input',
|
|
19
19
|
formatJson: 'Format JSON',
|
|
20
20
|
},
|
|
21
21
|
price: 'Price',
|
|
@@ -26,7 +26,7 @@ export default flat({
|
|
|
26
26
|
minLength: 'Min {len} characters',
|
|
27
27
|
invalidCharacters: 'Invalid characters',
|
|
28
28
|
latinOnly:
|
|
29
|
-
'
|
|
29
|
+
'Must contain at least one letter and cannot include Chinese characters or special characters such as <, >, ", \' or \\',
|
|
30
30
|
loading: 'Loading...',
|
|
31
31
|
rechargeTime: 'Recharge Time',
|
|
32
32
|
submit: 'Submit',
|
|
@@ -41,8 +41,8 @@ export default flat({
|
|
|
41
41
|
quickStarts: 'Quick Starts',
|
|
42
42
|
copy: 'Copy',
|
|
43
43
|
copied: 'Copied',
|
|
44
|
-
copySuccess: '
|
|
45
|
-
copyFailed: 'Copy
|
|
44
|
+
copySuccess: 'Copied successfully',
|
|
45
|
+
copyFailed: 'Copy failed',
|
|
46
46
|
copyTip: 'Please copy manually',
|
|
47
47
|
save: 'Save',
|
|
48
48
|
cancel: 'Cancel',
|
|
@@ -254,7 +254,7 @@ export default flat({
|
|
|
254
254
|
'Credit products are used to provide consumable credits to users, supporting both one-time purchase and package pricing models.',
|
|
255
255
|
creditAmount: {
|
|
256
256
|
label: 'Credit Amount',
|
|
257
|
-
placeholder: 'Amount of credits users
|
|
257
|
+
placeholder: 'Amount of credits users receive when purchasing this price',
|
|
258
258
|
help: 'Leave empty for pay-per-unit pricing, enter a number for fixed package deals',
|
|
259
259
|
description: 'Purchase quantity determines the amount of Credits received',
|
|
260
260
|
},
|
|
@@ -367,14 +367,14 @@ export default flat({
|
|
|
367
367
|
archiveTip: 'Archiving will hide this product from new purchases. Are you sure you want to archive this product?',
|
|
368
368
|
unarchive: 'Unarchive product',
|
|
369
369
|
unarchiveTip:
|
|
370
|
-
'Unarchiving will enable this product
|
|
370
|
+
'Unarchiving will enable this product for new purchases. Are you sure you want to unarchive this product?',
|
|
371
371
|
remove: 'Remove product',
|
|
372
372
|
removeTip: 'Removing will hide this product from new purchases. Are you sure you want to remove this product?',
|
|
373
373
|
archived: 'This product has been archived',
|
|
374
374
|
archivedTip:
|
|
375
375
|
"This product can't be added to new invoices, subscriptions, payment links, or pricing tables. Any existing subscriptions with this product remain active until canceled and any existing payment links or pricing tables are deactivated.",
|
|
376
376
|
locked: 'This product is locked because at least one of its prices is used by a subscription or a payment.',
|
|
377
|
-
currencyNotAligned: '
|
|
377
|
+
currencyNotAligned: 'All prices must have the same currency settings',
|
|
378
378
|
image: {
|
|
379
379
|
label: 'Image',
|
|
380
380
|
add: 'Add image',
|
|
@@ -431,7 +431,7 @@ export default flat({
|
|
|
431
431
|
vendorConfig: {
|
|
432
432
|
title: 'Vendor Configuration',
|
|
433
433
|
add: 'Add Vendor',
|
|
434
|
-
empty: 'No vendors configured. Click "Add Vendor" to configure vendor services
|
|
434
|
+
empty: 'No vendors configured. Click "Add Vendor" to configure vendor services',
|
|
435
435
|
vendor: 'Vendor',
|
|
436
436
|
vendorRequired: 'Vendor is required',
|
|
437
437
|
productCode: 'Product Code',
|
|
@@ -508,7 +508,7 @@ export default flat({
|
|
|
508
508
|
packageDesc: 'Price by number of units',
|
|
509
509
|
graduated: 'Graduated Pricing',
|
|
510
510
|
volume: 'Volume pricing',
|
|
511
|
-
custom: 'Customer
|
|
511
|
+
custom: 'Customer chooses price',
|
|
512
512
|
usageBased: 'Usage-based',
|
|
513
513
|
creditMetered: 'Credit metered',
|
|
514
514
|
},
|
|
@@ -520,7 +520,7 @@ export default flat({
|
|
|
520
520
|
selectMeter: 'Select meter',
|
|
521
521
|
pricingNote: 'For metered products, pricing is automatically based on usage tracked by the selected meter.',
|
|
522
522
|
description:
|
|
523
|
-
'Credit metered billing requires
|
|
523
|
+
'Credit metered billing requires corresponding credit consumption. If credits are consumed, the corresponding service will stop.',
|
|
524
524
|
},
|
|
525
525
|
credit: {
|
|
526
526
|
saveAsBasePrice: 'Set as Top-up Package',
|
|
@@ -557,7 +557,7 @@ export default flat({
|
|
|
557
557
|
tip: '',
|
|
558
558
|
},
|
|
559
559
|
quantity: {
|
|
560
|
-
tip: 'Quantity must be equal or greater than 0',
|
|
560
|
+
tip: 'Quantity must be equal to or greater than 0',
|
|
561
561
|
},
|
|
562
562
|
quantityAvailable: {
|
|
563
563
|
label: 'Available quantity',
|
|
@@ -597,10 +597,10 @@ export default flat({
|
|
|
597
597
|
afterPay: 'After payment',
|
|
598
598
|
products: 'Products',
|
|
599
599
|
addProduct: 'Add another product',
|
|
600
|
-
requireBillingAddress: 'Collect
|
|
601
|
-
requirePhoneNumber: 'Collect
|
|
600
|
+
requireBillingAddress: 'Collect customer billing addresses',
|
|
601
|
+
requirePhoneNumber: 'Collect customer phone numbers',
|
|
602
602
|
allowPromotionCodes: 'Allow promotion codes',
|
|
603
|
-
requireCrossSell: 'Require cross
|
|
603
|
+
requireCrossSell: 'Require cross-sell products to be selected if eligible',
|
|
604
604
|
includeFreeTrial: 'Include a free trial',
|
|
605
605
|
noStakeRequired: 'No stake required',
|
|
606
606
|
showProductFeatures: 'Show product features',
|
|
@@ -684,7 +684,7 @@ export default flat({
|
|
|
684
684
|
expiredUncapturedCharge: 'Expired uncaptured charge',
|
|
685
685
|
amountRange: 'Refund amount must be between {min} and {max} {symbol}',
|
|
686
686
|
amountHelper: 'Refund amount must be less than or equal to {max} {symbol}',
|
|
687
|
-
required: '
|
|
687
|
+
required: 'Please fill in the refund information',
|
|
688
688
|
empty: 'The current order has been fully refunded',
|
|
689
689
|
},
|
|
690
690
|
},
|
|
@@ -718,11 +718,11 @@ export default flat({
|
|
|
718
718
|
},
|
|
719
719
|
name: {
|
|
720
720
|
label: 'Name',
|
|
721
|
-
tip: '
|
|
721
|
+
tip: 'Customer-facing',
|
|
722
722
|
},
|
|
723
723
|
description: {
|
|
724
724
|
label: 'Description',
|
|
725
|
-
tip: 'Not
|
|
725
|
+
tip: 'Not customer-facing',
|
|
726
726
|
},
|
|
727
727
|
stripe: {
|
|
728
728
|
dashboard: {
|
|
@@ -749,11 +749,11 @@ export default flat({
|
|
|
749
749
|
},
|
|
750
750
|
api_host: {
|
|
751
751
|
label: 'API Host',
|
|
752
|
-
tip: 'The
|
|
752
|
+
tip: 'The GraphQL endpoint to send transactions to',
|
|
753
753
|
},
|
|
754
754
|
explorer_host: {
|
|
755
755
|
label: 'Explorer Host',
|
|
756
|
-
tip: 'The
|
|
756
|
+
tip: 'The web app endpoint to view transaction details',
|
|
757
757
|
},
|
|
758
758
|
},
|
|
759
759
|
ethereum: {
|
|
@@ -763,11 +763,11 @@ export default flat({
|
|
|
763
763
|
},
|
|
764
764
|
api_host: {
|
|
765
765
|
label: 'RPC Endpoint',
|
|
766
|
-
tip: 'The RPC endpoint to send
|
|
766
|
+
tip: 'The RPC endpoint to send transactions to',
|
|
767
767
|
},
|
|
768
768
|
explorer_host: {
|
|
769
769
|
label: 'Explorer Host',
|
|
770
|
-
tip: 'The
|
|
770
|
+
tip: 'The web app endpoint to view transaction details',
|
|
771
771
|
},
|
|
772
772
|
native_symbol: {
|
|
773
773
|
label: 'Native Symbol',
|
|
@@ -775,7 +775,7 @@ export default flat({
|
|
|
775
775
|
},
|
|
776
776
|
confirmation: {
|
|
777
777
|
label: 'Confirmation Count',
|
|
778
|
-
tip: '
|
|
778
|
+
tip: 'Number of blocks required since transaction execution',
|
|
779
779
|
},
|
|
780
780
|
},
|
|
781
781
|
evm: {
|
|
@@ -792,11 +792,11 @@ export default flat({
|
|
|
792
792
|
},
|
|
793
793
|
api_host: {
|
|
794
794
|
label: 'RPC Endpoint',
|
|
795
|
-
tip: 'The RPC endpoint to send
|
|
795
|
+
tip: 'The RPC endpoint to send transactions to',
|
|
796
796
|
},
|
|
797
797
|
explorer_host: {
|
|
798
798
|
label: 'Explorer Host',
|
|
799
|
-
tip: 'The
|
|
799
|
+
tip: 'The web app endpoint to view transaction details',
|
|
800
800
|
},
|
|
801
801
|
native_symbol: {
|
|
802
802
|
label: 'Native Symbol',
|
|
@@ -804,7 +804,7 @@ export default flat({
|
|
|
804
804
|
},
|
|
805
805
|
confirmation: {
|
|
806
806
|
label: 'Confirmation Count',
|
|
807
|
-
tip: '
|
|
807
|
+
tip: 'Number of blocks required since transaction execution',
|
|
808
808
|
},
|
|
809
809
|
},
|
|
810
810
|
},
|
|
@@ -905,6 +905,9 @@ export default flat({
|
|
|
905
905
|
appUrlRequired: 'App URL is required',
|
|
906
906
|
appUrlInvalid: 'Please enter a valid URL starting with http:// or https://',
|
|
907
907
|
appUrlHelp: 'The base URL of the vendor application',
|
|
908
|
+
vendorDid: 'Vendor DID',
|
|
909
|
+
vendorDidInvalid: 'Please enter a valid DID',
|
|
910
|
+
vendorDidHelp: 'Optional DID address for the vendor',
|
|
908
911
|
webhookPath: 'Webhook Path',
|
|
909
912
|
webhookPathInvalid: 'Please enter a valid path starting with /',
|
|
910
913
|
webhookPathHelp: 'Optional webhook callback path (e.g., /webhooks/status)',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -881,8 +881,11 @@ export default flat({
|
|
|
881
881
|
displayNameHelp: '此数据会展示到付款成功后的安装界面',
|
|
882
882
|
appUrl: '应用地址',
|
|
883
883
|
appUrlRequired: '应用地址是必填项',
|
|
884
|
-
appUrlInvalid: '
|
|
885
|
-
appUrlHelp: '供应商应用的基础URL',
|
|
884
|
+
appUrlInvalid: '请输入有效的 URL,以 http:// 或 https:// 开头',
|
|
885
|
+
appUrlHelp: '供应商应用的基础 URL 地址',
|
|
886
|
+
vendorDid: '供应商 DID',
|
|
887
|
+
vendorDidInvalid: '请输入有效的 DID',
|
|
888
|
+
vendorDidHelp: '供应商的可选 DID 地址',
|
|
886
889
|
webhookPath: 'Webhook路径',
|
|
887
890
|
webhookPathInvalid: '请输入以/开头的有效路径',
|
|
888
891
|
webhookPathHelp: '可选的webhook回调路径(如:/webhooks/status)',
|
|
@@ -30,6 +30,7 @@ interface Vendor {
|
|
|
30
30
|
name: string;
|
|
31
31
|
description: string;
|
|
32
32
|
app_url: string;
|
|
33
|
+
vendor_did?: string;
|
|
33
34
|
status: 'active' | 'inactive';
|
|
34
35
|
metadata: Record<string, any>;
|
|
35
36
|
created_at: string;
|
|
@@ -49,6 +50,7 @@ interface VendorFormData {
|
|
|
49
50
|
name: string;
|
|
50
51
|
description: string;
|
|
51
52
|
app_url: string;
|
|
53
|
+
vendor_did?: string;
|
|
52
54
|
status: 'active' | 'inactive';
|
|
53
55
|
metadata: Array<{ key: string; value: string }>;
|
|
54
56
|
app_pid?: string;
|
|
@@ -82,6 +84,7 @@ export default function VendorCreate({
|
|
|
82
84
|
name: '',
|
|
83
85
|
description: '',
|
|
84
86
|
app_url: '',
|
|
87
|
+
vendor_did: '',
|
|
85
88
|
status: 'inactive' as const,
|
|
86
89
|
metadata: [{ key: 'blockletMetaUrl', value: '' }],
|
|
87
90
|
app_pid: '',
|
|
@@ -114,6 +117,16 @@ export default function VendorCreate({
|
|
|
114
117
|
}
|
|
115
118
|
};
|
|
116
119
|
|
|
120
|
+
const validateDid = (did: string | undefined) => {
|
|
121
|
+
if (!did) return true; // DID 是可选的
|
|
122
|
+
// DID 格式验证
|
|
123
|
+
const didPattern = /^(did:abt:)?[1-9A-HJ-NP-Za-km-z]{37}$/;
|
|
124
|
+
if (!didPattern.test(did.trim())) {
|
|
125
|
+
return t('admin.vendor.vendorDidInvalid');
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
};
|
|
129
|
+
|
|
117
130
|
const onSubmit = async (data: VendorFormData) => {
|
|
118
131
|
try {
|
|
119
132
|
setLoading(true);
|
|
@@ -144,6 +157,7 @@ export default function VendorCreate({
|
|
|
144
157
|
|
|
145
158
|
const submitData = {
|
|
146
159
|
...restData,
|
|
160
|
+
vendor_did: restData.vendor_did?.replace('did:abt:', '').trim(),
|
|
147
161
|
metadata: metadataObj,
|
|
148
162
|
};
|
|
149
163
|
|
|
@@ -167,6 +181,10 @@ export default function VendorCreate({
|
|
|
167
181
|
// 从响应中获取appPid和appLogo
|
|
168
182
|
const blockletInfo = await response.json();
|
|
169
183
|
if (blockletInfo) {
|
|
184
|
+
const component = blockletInfo.componentMountPoints?.find((x: any) => x.did === submitData.vendor_did);
|
|
185
|
+
if (component && !['', '/'].includes(component.mountPoint) && submitData.metadata) {
|
|
186
|
+
submitData.metadata.mountPoint = component.mountPoint;
|
|
187
|
+
}
|
|
170
188
|
submitData.app_pid = blockletInfo.pid || blockletInfo.appPid;
|
|
171
189
|
submitData.app_logo = blockletInfo.logo || blockletInfo.appLogo;
|
|
172
190
|
}
|
|
@@ -307,6 +325,24 @@ export default function VendorCreate({
|
|
|
307
325
|
)}
|
|
308
326
|
/>
|
|
309
327
|
|
|
328
|
+
<Controller
|
|
329
|
+
name="vendor_did"
|
|
330
|
+
control={control}
|
|
331
|
+
rules={{
|
|
332
|
+
required: t('admin.vendor.vendorDidRequired'),
|
|
333
|
+
validate: validateDid,
|
|
334
|
+
}}
|
|
335
|
+
render={({ field }) => (
|
|
336
|
+
<TextField
|
|
337
|
+
{...field}
|
|
338
|
+
label={t('admin.vendor.vendorDid')}
|
|
339
|
+
fullWidth
|
|
340
|
+
error={!!errors.vendor_did}
|
|
341
|
+
helperText={errors.vendor_did?.message || t('admin.vendor.vendorDidHelp')}
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
344
|
+
/>
|
|
345
|
+
|
|
310
346
|
<MetadataForm title={t('common.metadata.label')} />
|
|
311
347
|
|
|
312
348
|
<Controller
|