payment-kit 1.21.8 → 1.21.10
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/libs/vendor-util/adapters/didnames-adapter.ts +171 -82
- package/api/src/queues/vendors/fulfillment-coordinator.ts +69 -0
- package/api/src/routes/checkout-sessions.ts +43 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/subscription/vendor-service-list.tsx +1 -1
|
@@ -18,6 +18,44 @@ import {
|
|
|
18
18
|
} from './types';
|
|
19
19
|
import { formatVendorUrl } from './util';
|
|
20
20
|
|
|
21
|
+
const DOMAIN_CONFLICT_ERROR_CODE = 'ERROR_DOMAIN_NOT_AVAILABLE';
|
|
22
|
+
|
|
23
|
+
export const parseDomainLength = (len: number | string | undefined, defaultValue: number) => {
|
|
24
|
+
try {
|
|
25
|
+
if (len === undefined) {
|
|
26
|
+
return defaultValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof len === 'number') {
|
|
30
|
+
return len;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return parseInt(len, 10) || defaultValue;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logger.error('failed to parse domain length', {
|
|
36
|
+
error,
|
|
37
|
+
len,
|
|
38
|
+
defaultValue,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return defaultValue;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const generateRandomSubdomain = (totalLength: number = 8): string => {
|
|
46
|
+
// Generate timestamp-based string for uniqueness
|
|
47
|
+
const timestamp = Date.now();
|
|
48
|
+
const timeStr = timestamp.toString(36);
|
|
49
|
+
let result = timeStr.slice(-Math.min(timeStr.length, totalLength));
|
|
50
|
+
|
|
51
|
+
// Pad with random chars if needed
|
|
52
|
+
while (result.length < totalLength) {
|
|
53
|
+
result = Math.floor(Math.random() * 36).toString(36) + result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result.slice(0, totalLength);
|
|
57
|
+
};
|
|
58
|
+
|
|
21
59
|
export class DidnamesAdapter implements VendorAdapter {
|
|
22
60
|
private vendorConfig: VendorConfig | null = null;
|
|
23
61
|
private vendorKey: string;
|
|
@@ -33,53 +71,128 @@ export class DidnamesAdapter implements VendorAdapter {
|
|
|
33
71
|
}
|
|
34
72
|
|
|
35
73
|
/**
|
|
36
|
-
*
|
|
37
|
-
* Format: [prefix][separator][timestamp-suffix] or pure timestamp
|
|
38
|
-
* Configurable length, prefix, separator with timestamp-based uniqueness
|
|
74
|
+
* Submit domain order with retry logic for domain conflicts
|
|
39
75
|
*/
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const suffixLength = totalLength - prefixWithSeparatorLength;
|
|
54
|
-
if (suffixLength <= 0) {
|
|
55
|
-
throw new Error(
|
|
56
|
-
`Total length (${totalLength}) must be greater than prefix + separator length (${prefixWithSeparatorLength})`
|
|
57
|
-
);
|
|
58
|
-
}
|
|
76
|
+
private async submitDomainOrderWithRetry(
|
|
77
|
+
orderData: any,
|
|
78
|
+
url: string,
|
|
79
|
+
rootDomain: string,
|
|
80
|
+
totalLength: number,
|
|
81
|
+
maxRetries: number = 5
|
|
82
|
+
): Promise<{ response: Response; subdomain: string; domain: string; bindDomainCap: any }> {
|
|
83
|
+
let lastError: Error | null = null;
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line no-await-in-loop
|
|
86
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
87
|
+
const subdomain = generateRandomSubdomain(totalLength);
|
|
88
|
+
const domain = `${subdomain}.${rootDomain}`;
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
logger.info(`Domain generation attempt ${attempt}/${maxRetries}`, {
|
|
91
|
+
subdomain,
|
|
92
|
+
domain,
|
|
93
|
+
checkoutSessionId: orderData.checkoutSessionId,
|
|
94
|
+
});
|
|
63
95
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
// Generate bindDomainCap for this domain
|
|
97
|
+
const bindDomainCap = this.generateBindCap({
|
|
98
|
+
domain,
|
|
99
|
+
checkoutSessionId: orderData.checkoutSessionId,
|
|
100
|
+
});
|
|
68
101
|
|
|
69
|
-
|
|
70
|
-
|
|
102
|
+
// Update order data with current domain info
|
|
103
|
+
const updatedOrderData = {
|
|
104
|
+
...orderData,
|
|
105
|
+
deliveryParams: {
|
|
106
|
+
...orderData.deliveryParams,
|
|
107
|
+
customParams: {
|
|
108
|
+
...orderData.deliveryParams.customParams,
|
|
109
|
+
subdomain,
|
|
110
|
+
rootDomain,
|
|
111
|
+
domain,
|
|
112
|
+
bindDomainCap,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
71
116
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
117
|
+
try {
|
|
118
|
+
const { headers, body } = VendorAuth.signRequestWithHeaders(updatedOrderData);
|
|
119
|
+
|
|
120
|
+
// eslint-disable-next-line no-await-in-loop
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers,
|
|
124
|
+
body,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (response.ok) {
|
|
128
|
+
logger.info('domain registration successful', {
|
|
129
|
+
subdomain,
|
|
130
|
+
domain,
|
|
131
|
+
attempt,
|
|
132
|
+
checkoutSessionId: orderData.checkoutSessionId,
|
|
133
|
+
});
|
|
134
|
+
return { response, subdomain, domain, bindDomainCap };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle error response
|
|
138
|
+
// eslint-disable-next-line no-await-in-loop
|
|
139
|
+
const errorBody = await response.text();
|
|
140
|
+
let errorData;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
errorData = JSON.parse(errorBody);
|
|
144
|
+
} catch {
|
|
145
|
+
errorData = { message: errorBody };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (errorData.code === DOMAIN_CONFLICT_ERROR_CODE && attempt < maxRetries) {
|
|
149
|
+
logger.warn('domain not available, retrying with new domain', {
|
|
150
|
+
subdomain,
|
|
151
|
+
domain,
|
|
152
|
+
attempt,
|
|
153
|
+
remainingAttempts: maxRetries - attempt,
|
|
154
|
+
checkoutSessionId: orderData.checkoutSessionId,
|
|
155
|
+
});
|
|
156
|
+
// eslint-disable-next-line no-continue
|
|
157
|
+
continue; // Try again with new domain
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const errorMsg =
|
|
161
|
+
errorData.code === DOMAIN_CONFLICT_ERROR_CODE
|
|
162
|
+
? `failed to find available domain after ${maxRetries} attempts`
|
|
163
|
+
: `did names API error: ${response.status} ${response.statusText} - ${errorData.message || errorBody}`;
|
|
164
|
+
|
|
165
|
+
logger.error('did names API error', {
|
|
166
|
+
url,
|
|
167
|
+
status: response.status,
|
|
168
|
+
statusText: response.statusText,
|
|
169
|
+
body: errorBody,
|
|
170
|
+
subdomain,
|
|
171
|
+
attempt,
|
|
172
|
+
isDomainConflict: errorData.code === DOMAIN_CONFLICT_ERROR_CODE,
|
|
173
|
+
});
|
|
76
174
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
175
|
+
throw new Error(errorMsg);
|
|
176
|
+
} catch (error: any) {
|
|
177
|
+
lastError = error;
|
|
178
|
+
|
|
179
|
+
// If it's a network error and not max retries, continue
|
|
180
|
+
if (attempt < maxRetries && !error.message.includes('did names API error')) {
|
|
181
|
+
logger.warn('network error during domain registration, retrying', {
|
|
182
|
+
error: error.message,
|
|
183
|
+
attempt,
|
|
184
|
+
remainingAttempts: maxRetries - attempt,
|
|
185
|
+
checkoutSessionId: orderData.checkoutSessionId,
|
|
186
|
+
});
|
|
187
|
+
// eslint-disable-next-line no-continue
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
80
193
|
}
|
|
81
194
|
|
|
82
|
-
|
|
195
|
+
throw lastError || new Error('Unknown error occurred during domain registration');
|
|
83
196
|
}
|
|
84
197
|
|
|
85
198
|
/**
|
|
@@ -132,57 +245,33 @@ export class DidnamesAdapter implements VendorAdapter {
|
|
|
132
245
|
|
|
133
246
|
logger.info('didnames vendor rootDomain', { rootDomain });
|
|
134
247
|
|
|
135
|
-
const
|
|
136
|
-
const domain = `${subdomain}.${rootDomain}`;
|
|
137
|
-
|
|
138
|
-
const { checkoutSessionId } = params;
|
|
139
|
-
const bindDomainCap = this.generateBindCap({
|
|
140
|
-
domain,
|
|
141
|
-
checkoutSessionId,
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
params.deliveryParams.customParams = {
|
|
145
|
-
...params.deliveryParams.customParams,
|
|
146
|
-
years: 1,
|
|
147
|
-
whoisPrivacy: true,
|
|
148
|
-
subdomain,
|
|
149
|
-
rootDomain,
|
|
150
|
-
domain,
|
|
151
|
-
checkoutSessionId,
|
|
152
|
-
bindDomainCap,
|
|
153
|
-
};
|
|
248
|
+
const totalLength = parseDomainLength(vendorConfig.metadata?.subDomainLength, 8);
|
|
154
249
|
|
|
155
|
-
|
|
250
|
+
// Prepare base order data
|
|
251
|
+
const baseOrderData = {
|
|
156
252
|
checkoutSessionId: params.checkoutSessionId,
|
|
157
253
|
description: params.description,
|
|
158
254
|
userInfo: params.userInfo,
|
|
159
|
-
deliveryParams:
|
|
255
|
+
deliveryParams: {
|
|
256
|
+
...params.deliveryParams,
|
|
257
|
+
customParams: {
|
|
258
|
+
...params.deliveryParams.customParams,
|
|
259
|
+
years: 1,
|
|
260
|
+
whoisPrivacy: true,
|
|
261
|
+
checkoutSessionId: params.checkoutSessionId,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
160
264
|
};
|
|
161
265
|
|
|
162
266
|
const url = formatVendorUrl(vendorConfig, '/api/vendor/deliveries');
|
|
163
|
-
logger.info('submitting domain delivery to DID Names', {
|
|
164
|
-
subdomain,
|
|
165
|
-
url,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const { headers, body } = VendorAuth.signRequestWithHeaders(orderData);
|
|
169
|
-
|
|
170
|
-
const response = await fetch(url, {
|
|
171
|
-
method: 'POST',
|
|
172
|
-
headers,
|
|
173
|
-
body,
|
|
174
|
-
});
|
|
175
267
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
});
|
|
184
|
-
throw new Error(`DID Names API error: ${response.status} ${response.statusText}`);
|
|
185
|
-
}
|
|
268
|
+
// Use the retry wrapper to submit order with domain conflict handling
|
|
269
|
+
const { response, subdomain, domain, bindDomainCap } = await this.submitDomainOrderWithRetry(
|
|
270
|
+
baseOrderData,
|
|
271
|
+
url,
|
|
272
|
+
rootDomain,
|
|
273
|
+
totalLength
|
|
274
|
+
);
|
|
186
275
|
|
|
187
276
|
const didNamesResult = await response.json();
|
|
188
277
|
|
|
@@ -4,14 +4,18 @@ import { events } from '../../libs/event';
|
|
|
4
4
|
import { getLock } from '../../libs/lock';
|
|
5
5
|
import logger from '../../libs/logger';
|
|
6
6
|
import createQueue from '../../libs/queue';
|
|
7
|
+
import dayjs from '../../libs/dayjs';
|
|
7
8
|
import { VendorFulfillmentService } from '../../libs/vendor-util/fulfillment';
|
|
8
9
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
9
10
|
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
10
11
|
import { Price } from '../../store/models/price';
|
|
11
12
|
import { Product } from '../../store/models/product';
|
|
12
13
|
import { Refund } from '../../store/models/refund';
|
|
14
|
+
import { Subscription } from '../../store/models/subscription';
|
|
13
15
|
import { sequelize } from '../../store/sequelize';
|
|
14
16
|
import { depositVaultQueue } from '../payment';
|
|
17
|
+
import { addSubscriptionJob } from '../subscription';
|
|
18
|
+
import { SubscriptionWillCanceledSchedule } from '../../crons/subscription-will-canceled';
|
|
15
19
|
import { Invoice } from '../../store/models';
|
|
16
20
|
|
|
17
21
|
export type VendorInfo = NonNullable<CheckoutSession['vendor_info']>[number];
|
|
@@ -634,6 +638,11 @@ export async function initiateFullRefund(invoiceId: string, reason: string): Pro
|
|
|
634
638
|
await checkoutSession.update({ fulfillment_status: 'cancelled' });
|
|
635
639
|
await requestReturnsFromCompletedVendors(checkoutSession, reason);
|
|
636
640
|
|
|
641
|
+
// Cancel subscription if this refund is for a subscription
|
|
642
|
+
if (checkoutSession.subscription_id) {
|
|
643
|
+
await cancelSubscriptionForRefund(checkoutSession.subscription_id, reason);
|
|
644
|
+
}
|
|
645
|
+
|
|
637
646
|
// Calculate remaining amount using the same logic as subscription createProration
|
|
638
647
|
const refunds = await Refund.findAll({
|
|
639
648
|
where: {
|
|
@@ -707,6 +716,66 @@ export async function initiateFullRefund(invoiceId: string, reason: string): Pro
|
|
|
707
716
|
}
|
|
708
717
|
}
|
|
709
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Cancel subscription when full refund is initiated due to vendor fulfillment failure
|
|
721
|
+
*/
|
|
722
|
+
async function cancelSubscriptionForRefund(subscriptionId: string, reason: string): Promise<void> {
|
|
723
|
+
try {
|
|
724
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
725
|
+
if (!subscription) {
|
|
726
|
+
logger.warn('Subscription not found for cancellation', { subscriptionId });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Check if subscription is already canceled
|
|
731
|
+
if (subscription.status === 'canceled') {
|
|
732
|
+
logger.info('Subscription already canceled, skipping', { subscriptionId });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const now = dayjs().unix() + 3;
|
|
737
|
+
const haveStake = !!subscription.payment_details?.arcblock?.staking?.tx_hash;
|
|
738
|
+
|
|
739
|
+
// Prepare cancellation details
|
|
740
|
+
const updates: Partial<Subscription> = {
|
|
741
|
+
status: 'canceled',
|
|
742
|
+
cancel_at: now,
|
|
743
|
+
canceled_at: now,
|
|
744
|
+
cancelation_details: {
|
|
745
|
+
comment: `Canceled due to vendor fulfillment failure: ${reason}`,
|
|
746
|
+
reason: 'vendor_fulfillment_failed',
|
|
747
|
+
feedback: 'vendor_issue',
|
|
748
|
+
return_stake: haveStake, // Return stake when canceled due to vendor failure
|
|
749
|
+
slash_stake: false,
|
|
750
|
+
slash_reason: '',
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// Update subscription
|
|
755
|
+
await subscription.update(updates);
|
|
756
|
+
|
|
757
|
+
// Schedule cancellation job
|
|
758
|
+
await addSubscriptionJob(subscription, 'cancel', true, updates.cancel_at);
|
|
759
|
+
|
|
760
|
+
// Update scheduled tasks
|
|
761
|
+
await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
|
|
762
|
+
|
|
763
|
+
logger.info('Subscription canceled due to vendor fulfillment failure', {
|
|
764
|
+
subscriptionId: subscription.id,
|
|
765
|
+
customerId: subscription.customer_id,
|
|
766
|
+
reason,
|
|
767
|
+
cancelAt: subscription.cancel_at,
|
|
768
|
+
returnStake: haveStake,
|
|
769
|
+
});
|
|
770
|
+
} catch (error: any) {
|
|
771
|
+
logger.error('Failed to cancel subscription for refund', {
|
|
772
|
+
subscriptionId,
|
|
773
|
+
reason,
|
|
774
|
+
error,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
710
779
|
async function requestReturnsFromCompletedVendors(checkoutSession: CheckoutSession, reason: string): Promise<void> {
|
|
711
780
|
logger.info('Starting return request process', {
|
|
712
781
|
checkoutSessionId: checkoutSession.id,
|
|
@@ -1329,6 +1329,49 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1329
1329
|
});
|
|
1330
1330
|
});
|
|
1331
1331
|
|
|
1332
|
+
// for checkout page
|
|
1333
|
+
router.get('/broker-status/:id', user, async (req, res) => {
|
|
1334
|
+
const { needShortUrl = false } = req.query;
|
|
1335
|
+
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
1336
|
+
|
|
1337
|
+
if (!doc) {
|
|
1338
|
+
res.json({
|
|
1339
|
+
checkoutSession: {},
|
|
1340
|
+
paymentLink: null,
|
|
1341
|
+
});
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// @ts-ignore
|
|
1346
|
+
doc.line_items = await Price.expand(doc.line_items, { upsell: true });
|
|
1347
|
+
|
|
1348
|
+
const hasVendorConfig = doc.line_items?.some((item: any) => !!item?.price?.product?.vendor_config?.length);
|
|
1349
|
+
|
|
1350
|
+
if (!hasVendorConfig || doc.payment_status === 'unpaid' || doc.fulfillment_status === 'cancelled') {
|
|
1351
|
+
res.json({
|
|
1352
|
+
checkoutSession: {},
|
|
1353
|
+
paymentLink: null,
|
|
1354
|
+
});
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const paymentUrl = getUrl(`/checkout/pay/${doc.id}`);
|
|
1359
|
+
const paymentLink = needShortUrl
|
|
1360
|
+
? await formatToShortUrl({
|
|
1361
|
+
url: paymentUrl,
|
|
1362
|
+
validUntil: dayjs().add(20, 'minutes').format('YYYY-MM-DDTHH:mm:ss+00:00'),
|
|
1363
|
+
maxVisits: 5,
|
|
1364
|
+
})
|
|
1365
|
+
: paymentUrl;
|
|
1366
|
+
|
|
1367
|
+
res.json({
|
|
1368
|
+
checkoutSession: {
|
|
1369
|
+
...doc.toJSON(),
|
|
1370
|
+
},
|
|
1371
|
+
paymentLink,
|
|
1372
|
+
});
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1332
1375
|
async function checkVendorConfig(items: TLineItemExpanded[]) {
|
|
1333
1376
|
const lineItems = await Price.expand(items, { upsell: true });
|
|
1334
1377
|
return lineItems?.some((item: TLineItemExpanded) => !!item?.price?.product?.vendor_config?.length);
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.10",
|
|
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,9 +56,9 @@
|
|
|
56
56
|
"@blocklet/error": "^0.2.5",
|
|
57
57
|
"@blocklet/js-sdk": "^1.16.52",
|
|
58
58
|
"@blocklet/logger": "^1.16.52",
|
|
59
|
-
"@blocklet/payment-broker-client": "1.21.
|
|
60
|
-
"@blocklet/payment-react": "1.21.
|
|
61
|
-
"@blocklet/payment-vendor": "1.21.
|
|
59
|
+
"@blocklet/payment-broker-client": "1.21.10",
|
|
60
|
+
"@blocklet/payment-react": "1.21.10",
|
|
61
|
+
"@blocklet/payment-vendor": "1.21.10",
|
|
62
62
|
"@blocklet/sdk": "^1.16.52",
|
|
63
63
|
"@blocklet/ui-react": "^3.1.46",
|
|
64
64
|
"@blocklet/uploader": "^0.2.13",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"devDependencies": {
|
|
129
129
|
"@abtnode/types": "^1.16.52",
|
|
130
130
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
131
|
-
"@blocklet/payment-types": "1.21.
|
|
131
|
+
"@blocklet/payment-types": "1.21.10",
|
|
132
132
|
"@types/cookie-parser": "^1.4.9",
|
|
133
133
|
"@types/cors": "^2.8.19",
|
|
134
134
|
"@types/debug": "^4.1.12",
|
|
@@ -175,5 +175,5 @@
|
|
|
175
175
|
"parser": "typescript"
|
|
176
176
|
}
|
|
177
177
|
},
|
|
178
|
-
"gitHead": "
|
|
178
|
+
"gitHead": "a7288f2742e4bb2622505b79dd539616a6a59878"
|
|
179
179
|
}
|
|
@@ -82,7 +82,7 @@ export default function VendorServiceList({
|
|
|
82
82
|
{vendor.name || vendor.vendor_key}
|
|
83
83
|
</Typography>
|
|
84
84
|
</Stack>
|
|
85
|
-
{isLauncher && (
|
|
85
|
+
{isLauncher && !isCanceled && (
|
|
86
86
|
<Box>
|
|
87
87
|
<Stack direction="row" spacing={0.5}>
|
|
88
88
|
<Tooltip title={t('admin.subscription.serviceHome')} placement="top">
|