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.
@@ -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
- * Generate random subdomain for documentation site
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 generateRandomSubdomain(
41
- options: {
42
- totalLength?: number; // Total subdomain length (default: 9)
43
- prefix?: string; // Prefix (default: 'doc')
44
- usePrefix?: boolean; // Whether to use prefix (default: true)
45
- separator?: string; // Separator between prefix and suffix (default: '-')
46
- } = {}
47
- ): string {
48
- const { totalLength = 8, prefix = 'doc', usePrefix = true, separator = '-' } = options;
49
-
50
- if (usePrefix) {
51
- // With prefix: 'doc' + separator + timestamp suffix
52
- const prefixWithSeparatorLength = prefix.length + separator.length;
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
- const timestamp = Date.now();
61
- const timeStr = timestamp.toString(36);
62
- let suffix = timeStr.slice(-suffixLength);
90
+ logger.info(`Domain generation attempt ${attempt}/${maxRetries}`, {
91
+ subdomain,
92
+ domain,
93
+ checkoutSessionId: orderData.checkoutSessionId,
94
+ });
63
95
 
64
- // Pad with random chars if timestamp is shorter than needed
65
- while (suffix.length < suffixLength) {
66
- suffix = Math.floor(Math.random() * 36).toString(36) + suffix;
67
- }
96
+ // Generate bindDomainCap for this domain
97
+ const bindDomainCap = this.generateBindCap({
98
+ domain,
99
+ checkoutSessionId: orderData.checkoutSessionId,
100
+ });
68
101
 
69
- return `${prefix}${separator}${suffix}`;
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
- // Pure timestamp-based without prefix
73
- const timestamp = Date.now();
74
- const timeStr = timestamp.toString(36);
75
- let result = timeStr.slice(-totalLength);
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
- // Pad with random chars if needed
78
- while (result.length < totalLength) {
79
- result = Math.floor(Math.random() * 36).toString(36) + result;
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
- return result;
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 subdomain = this.generateRandomSubdomain({ totalLength: 8 });
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
- const orderData = {
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: params.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
- if (!response.ok) {
177
- const errorBody = await response.text();
178
- logger.error('DID Names API error', {
179
- url,
180
- status: response.status,
181
- statusText: response.statusText,
182
- body: errorBody,
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
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.21.8
17
+ version: 1.21.10
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.21.8",
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.8",
60
- "@blocklet/payment-react": "1.21.8",
61
- "@blocklet/payment-vendor": "1.21.8",
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.8",
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": "d97f6b353583b95b2e0ec49ea66ab45426658532"
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">