payment-kit 1.21.15 → 1.21.17

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.
Files changed (30) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +30 -25
  3. package/api/src/integrations/stripe/handlers/setup-intent.ts +231 -0
  4. package/api/src/integrations/stripe/handlers/subscription.ts +31 -9
  5. package/api/src/integrations/stripe/resource.ts +29 -0
  6. package/api/src/libs/payment.ts +9 -3
  7. package/api/src/libs/util.ts +17 -0
  8. package/api/src/queues/vendors/return-processor.ts +52 -75
  9. package/api/src/queues/vendors/return-scanner.ts +38 -3
  10. package/api/src/routes/connect/change-payer.ts +148 -0
  11. package/api/src/routes/connect/shared.ts +30 -0
  12. package/api/src/routes/invoices.ts +141 -2
  13. package/api/src/routes/payment-links.ts +2 -1
  14. package/api/src/routes/subscriptions.ts +130 -3
  15. package/api/src/routes/vendor.ts +100 -72
  16. package/api/src/store/models/checkout-session.ts +1 -0
  17. package/blocklet.yml +1 -1
  18. package/package.json +6 -6
  19. package/src/components/invoice-pdf/template.tsx +30 -0
  20. package/src/components/subscription/payment-method-info.tsx +222 -0
  21. package/src/global.css +4 -0
  22. package/src/locales/en.tsx +13 -0
  23. package/src/locales/zh.tsx +13 -0
  24. package/src/pages/admin/billing/invoices/detail.tsx +5 -3
  25. package/src/pages/admin/billing/subscriptions/detail.tsx +16 -0
  26. package/src/pages/admin/overview.tsx +14 -14
  27. package/src/pages/admin/products/vendors/create.tsx +6 -40
  28. package/src/pages/admin/products/vendors/index.tsx +5 -1
  29. package/src/pages/customer/invoice/detail.tsx +59 -17
  30. package/src/pages/customer/subscription/detail.tsx +20 -1
@@ -238,7 +238,7 @@ router.get('/search', auth, async (req, res) => {
238
238
 
239
239
  router.get('/:id', authPortal, async (req, res) => {
240
240
  try {
241
- const doc = await Subscription.findOne({
241
+ const doc = (await Subscription.findOne({
242
242
  where: { id: req.params.id },
243
243
  include: [
244
244
  { model: PaymentCurrency, as: 'paymentCurrency' },
@@ -246,10 +246,15 @@ router.get('/:id', authPortal, async (req, res) => {
246
246
  { model: SubscriptionItem, as: 'items' },
247
247
  { model: Customer, as: 'customer' },
248
248
  ],
249
- });
249
+ })) as Subscription & {
250
+ paymentMethod: PaymentMethod;
251
+ paymentCurrency: PaymentCurrency;
252
+ items: SubscriptionItem[];
253
+ customer: Customer;
254
+ };
250
255
 
251
256
  if (doc) {
252
- const json = doc.toJSON();
257
+ const json: any = doc.toJSON();
253
258
  const isConsumesCredit = await doc.isConsumesCredit();
254
259
  const serviceType = isConsumesCredit ? 'credit' : 'standard';
255
260
  const products = (await Product.findAll()).map((x) => x.toJSON());
@@ -270,9 +275,70 @@ router.get('/:id', authPortal, async (req, res) => {
270
275
  logger.error('Failed to fetch subscription discount stats', { error, subscriptionId: json.id });
271
276
  }
272
277
 
278
+ // Get payment method details
279
+ let paymentMethodDetails = null;
280
+ try {
281
+ const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
282
+ if (paymentMethod?.type === 'stripe' && json.payment_details?.stripe?.subscription_id) {
283
+ const client = paymentMethod.getStripeClient();
284
+ const stripeSubscription = await client.subscriptions.retrieve(json.payment_details.stripe.subscription_id, {
285
+ expand: ['default_payment_method'],
286
+ });
287
+
288
+ if (stripeSubscription.default_payment_method) {
289
+ const paymentMethodId =
290
+ typeof stripeSubscription.default_payment_method === 'string'
291
+ ? stripeSubscription.default_payment_method
292
+ : stripeSubscription.default_payment_method.id;
293
+
294
+ const paymentMethodData = await client.paymentMethods.retrieve(paymentMethodId);
295
+
296
+ paymentMethodDetails = {
297
+ id: paymentMethodData.id,
298
+ type: paymentMethodData.type,
299
+ billing_details: paymentMethodData.billing_details,
300
+ } as any;
301
+
302
+ if (paymentMethodData.card) {
303
+ paymentMethodDetails.card = {
304
+ brand: paymentMethodData.card.brand,
305
+ last4: paymentMethodData.card.last4,
306
+ exp_month: paymentMethodData.card.exp_month,
307
+ exp_year: paymentMethodData.card.exp_year,
308
+ };
309
+ }
310
+
311
+ if (paymentMethodData.link) {
312
+ paymentMethodDetails.link = {
313
+ email: paymentMethodData.link.email,
314
+ };
315
+ }
316
+
317
+ if (paymentMethodData.us_bank_account) {
318
+ paymentMethodDetails.us_bank_account = {
319
+ account_type: paymentMethodData.us_bank_account.account_type,
320
+ bank_name: paymentMethodData.us_bank_account.bank_name,
321
+ last4: paymentMethodData.us_bank_account.last4,
322
+ };
323
+ }
324
+ }
325
+ } else if (doc.paymentMethod) {
326
+ const payer = getSubscriptionPaymentAddress(doc, doc.paymentMethod.type);
327
+ if (payer) {
328
+ paymentMethodDetails = {
329
+ type: doc.paymentMethod.type,
330
+ payer,
331
+ };
332
+ }
333
+ }
334
+ } catch (error) {
335
+ logger.error('Failed to fetch payment method details', { error, subscriptionId: json.id });
336
+ }
337
+
273
338
  res.json({
274
339
  ...json,
275
340
  discountStats,
341
+ paymentMethodDetails,
276
342
  });
277
343
  } else {
278
344
  res.status(404).json(null);
@@ -2283,4 +2349,65 @@ router.get('/:id/change-payment/migrate-invoice', auth, async (req, res) => {
2283
2349
  return res.status(400).json({ error: error.message });
2284
2350
  }
2285
2351
  });
2352
+
2353
+ router.post('/:id/update-stripe-payment-method', authPortal, async (req, res) => {
2354
+ try {
2355
+ const subscription = await Subscription.findByPk(req.params.id);
2356
+ if (!subscription) {
2357
+ return res.status(404).json({ error: 'Subscription not found' });
2358
+ }
2359
+
2360
+ if (!['active', 'trialing', 'past_due'].includes(subscription.status)) {
2361
+ return res.status(400).json({ error: 'Subscription is not active' });
2362
+ }
2363
+
2364
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
2365
+ if (!paymentMethod || paymentMethod.type !== 'stripe') {
2366
+ return res.status(400).json({ error: 'Subscription is not using Stripe payment method' });
2367
+ }
2368
+
2369
+ const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id;
2370
+ if (!stripeSubscriptionId) {
2371
+ return res.status(400).json({ error: 'Stripe subscription not found' });
2372
+ }
2373
+
2374
+ const customer = await Customer.findByPk(subscription.customer_id);
2375
+ if (!customer) {
2376
+ return res.status(404).json({ error: 'Customer not found' });
2377
+ }
2378
+
2379
+ await ensureStripeCustomer(customer, paymentMethod);
2380
+
2381
+ const client = paymentMethod.getStripeClient();
2382
+ const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
2383
+
2384
+ const setupIntent = await client.setupIntents.create({
2385
+ customer: subscription.payment_details?.stripe?.customer_id,
2386
+ payment_method_types: ['card'],
2387
+ usage: 'off_session',
2388
+ metadata: {
2389
+ subscription_id: subscription.id,
2390
+ action: 'update_payment_method',
2391
+ },
2392
+ });
2393
+
2394
+ logger.info('Setup intent created for updating stripe payment method', {
2395
+ subscription: subscription.id,
2396
+ setupIntent: setupIntent.id,
2397
+ });
2398
+
2399
+ return res.json({
2400
+ client_secret: setupIntent.client_secret,
2401
+ publishable_key: settings.stripe?.publishable_key,
2402
+ setup_intent_id: setupIntent.id,
2403
+ });
2404
+ } catch (err) {
2405
+ logger.error('Failed to create setup intent for updating payment method', {
2406
+ error: err,
2407
+ subscriptionId: req.params.id,
2408
+ });
2409
+ return res.status(400).json({ error: err.message });
2410
+ }
2411
+ });
2412
+
2286
2413
  export default router;
@@ -5,6 +5,7 @@ import Joi from 'joi';
5
5
 
6
6
  // eslint-disable-next-line import/no-extraneous-dependencies
7
7
  import { gte } from 'semver';
8
+ import { Op } from 'sequelize';
8
9
  import { MetadataSchema } from '../libs/api';
9
10
  import { wallet } from '../libs/auth';
10
11
  import dayjs from '../libs/dayjs';
@@ -31,19 +32,15 @@ const createVendorSchema = Joi.object({
31
32
  name: Joi.string().max(255).required(),
32
33
  description: Joi.string().max(1000).allow('').optional(),
33
34
  app_url: Joi.string().uri().max(512).required(),
34
- app_pid: Joi.string().max(255).allow('').optional(),
35
- app_logo: Joi.string().max(512).allow('').optional(),
36
35
  status: Joi.string().valid('active', 'inactive').default('active'),
37
36
  metadata: MetadataSchema,
38
- }).unknown(false);
37
+ }).unknown(true);
39
38
 
40
39
  const updateVendorSchema = Joi.object({
41
40
  vendor_type: Joi.string().valid('launcher', 'didnames').optional(),
42
41
  name: Joi.string().max(255).optional(),
43
42
  description: Joi.string().max(1000).allow('').optional(),
44
43
  app_url: Joi.string().uri().max(512).optional(),
45
- app_pid: Joi.string().max(255).allow('').optional(),
46
- app_logo: Joi.string().max(512).allow('').optional(),
47
44
  status: Joi.string().valid('active', 'inactive').optional(),
48
45
  metadata: MetadataSchema,
49
46
  }).unknown(true);
@@ -56,6 +53,10 @@ const sessionIdParamSchema = Joi.object({
56
53
  sessionId: Joi.string().max(100).required(),
57
54
  });
58
55
 
56
+ const sessionIdsParamSchema = Joi.object({
57
+ sessionIds: Joi.array().items(Joi.string().max(100)).required(),
58
+ });
59
+
59
60
  const subscriptionIdParamSchema = Joi.object({
60
61
  subscriptionId: Joi.string().max(100).required(),
61
62
  });
@@ -134,6 +135,51 @@ async function getVendorInfo(req: any, res: any) {
134
135
  }
135
136
  }
136
137
 
138
+ async function prepareVendorData(appUrlInput: string, vendorType: 'launcher' | 'didnames', metadata: any = {}) {
139
+ let appUrl = '';
140
+ let blockletJson = null;
141
+ try {
142
+ appUrl = new URL(appUrlInput).origin;
143
+ blockletJson = await getBlockletJson(appUrl);
144
+ } catch (error) {
145
+ logger.error('Failed to get blocklet json', {
146
+ appUrlInput,
147
+ error,
148
+ });
149
+ return { error: `Invalid app URL: ${appUrlInput}, get blocklet json failed` as const };
150
+ }
151
+
152
+ if (!blockletJson?.appId || !blockletJson?.appPk) {
153
+ return { error: `Invalid app URL: ${appUrl}, the appId or appPk is required in the target app` as const };
154
+ }
155
+
156
+ const vendorDid = VENDOR_DID[vendorType];
157
+ const component = blockletJson?.componentMountPoints?.find((item: any) => item.did === vendorDid);
158
+
159
+ if (!component) {
160
+ return { error: `Invalid app URL: ${appUrl}, the ${vendorType} did is not found in the target server` as const };
161
+ }
162
+
163
+ const mountPoint = component.mountPoint || '/';
164
+ return {
165
+ vendor_did: vendorDid,
166
+ app_url: appUrl,
167
+ // Both appPid and appId can be used here for transfer purposes, with did being recommended.
168
+ // Keeping appPid for now due to extensive changes required
169
+ app_pid: blockletJson.appId,
170
+ app_logo: blockletJson.appLogo,
171
+ metadata: {
172
+ ...metadata,
173
+ mountPoint,
174
+ },
175
+ extends: {
176
+ mountPoint,
177
+ appId: blockletJson.appId,
178
+ appPk: blockletJson.appPk,
179
+ },
180
+ };
181
+ }
182
+
137
183
  async function createVendor(req: any, res: any) {
138
184
  try {
139
185
  const { error, value } = createVendorSchema.validate(req.body);
@@ -144,26 +190,9 @@ async function createVendor(req: any, res: any) {
144
190
  });
145
191
  }
146
192
 
147
- const {
148
- vendor_key: vendorKey,
149
- vendor_type: type,
150
- name,
151
- description,
152
- metadata,
153
- app_pid: appPid,
154
- app_logo: appLogo,
155
- status,
156
- } = value;
157
-
158
- let appUrl = '';
159
- try {
160
- appUrl = new URL(value.app_url).origin;
161
- } catch {
162
- return res.status(400).json({ error: 'Invalid app URL' });
163
- }
193
+ const { vendor_key: vendorKey, vendor_type: type, name, description, metadata, status } = value;
164
194
 
165
195
  const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
166
- const vendorDid = VENDOR_DID[vendorType];
167
196
 
168
197
  const existingVendor = await ProductVendor.findOne({
169
198
  where: { vendor_key: vendorKey },
@@ -172,30 +201,18 @@ async function createVendor(req: any, res: any) {
172
201
  return res.status(400).json({ error: 'Vendor key already exists' });
173
202
  }
174
203
 
175
- const blockletJson = await getBlockletJson(appUrl);
176
-
177
- const mountPoint =
178
- blockletJson?.componentMountPoints?.find((item: any) => item.did === vendorDid)?.mountPoint || '/';
204
+ const preparedData = await prepareVendorData(value.app_url, vendorType, metadata);
205
+ if ('error' in preparedData) {
206
+ return res.status(400).json({ error: preparedData.error });
207
+ }
179
208
 
180
209
  const vendor = await ProductVendor.create({
210
+ ...preparedData,
181
211
  vendor_key: vendorKey,
182
212
  vendor_type: vendorType,
183
213
  name,
184
214
  description,
185
- app_url: appUrl,
186
- vendor_did: vendorDid,
187
215
  status: status || 'active',
188
- app_pid: appPid,
189
- app_logo: appLogo,
190
- metadata: {
191
- ...metadata,
192
- mountPoint,
193
- },
194
- extends: {
195
- mountPoint,
196
- appId: blockletJson?.appId,
197
- appPk: blockletJson?.appPk,
198
- },
199
216
  created_by: req.user?.did || 'admin',
200
217
  });
201
218
 
@@ -224,50 +241,30 @@ async function updateVendor(req: any, res: any) {
224
241
  });
225
242
  }
226
243
 
227
- const { vendor_type: type, name, description, status, metadata, app_pid: appPid, app_logo: appLogo } = value;
228
-
229
- let appUrl = '';
230
- try {
231
- appUrl = new URL(value.app_url).origin;
232
- } catch {
233
- return res.status(400).json({ error: 'Invalid app URL' });
234
- }
235
-
236
- const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
237
- const vendorDid = VENDOR_DID[vendorType];
238
-
239
- const blockletJson = await getBlockletJson(appUrl);
240
-
241
- const mountPoint =
242
- blockletJson?.componentMountPoints?.find((item: any) => item.did === vendorDid)?.mountPoint || '/';
244
+ const { vendor_type: type, vendor_key: vendorKey, name, description, status, metadata } = value;
243
245
 
244
- if (req.body.vendorKey && req.body.vendorKey !== vendor.vendor_key) {
246
+ if (vendorKey && vendorKey !== vendor.vendor_key) {
245
247
  const existingVendor = await ProductVendor.findOne({
246
- where: { vendor_key: req.body.vendorKey },
248
+ where: { vendor_key: vendorKey },
247
249
  });
248
250
  if (existingVendor) {
249
251
  return res.status(400).json({ error: 'Vendor key already exists' });
250
252
  }
251
253
  }
254
+
255
+ const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
256
+ const preparedData = await prepareVendorData(value.app_url, vendorType, metadata);
257
+ if ('error' in preparedData) {
258
+ return res.status(400).json({ error: preparedData.error });
259
+ }
260
+
252
261
  const updates = {
262
+ ...preparedData,
253
263
  vendor_type: vendorType,
264
+ vendor_key: vendorKey,
254
265
  name,
255
266
  description,
256
- app_url: appUrl,
257
- vendor_did: vendorDid,
258
267
  status,
259
- metadata: {
260
- ...metadata,
261
- mountPoint,
262
- },
263
- app_pid: appPid,
264
- app_logo: appLogo,
265
- vendor_key: req.body.vendor_key,
266
- extends: {
267
- mountPoint,
268
- appId: blockletJson?.appId,
269
- appPk: blockletJson?.appPk,
270
- },
271
268
  };
272
269
 
273
270
  await vendor.update(Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined)));
@@ -561,6 +558,36 @@ async function redirectToVendor(req: any, res: any) {
561
558
  }
562
559
  }
563
560
 
561
+ async function getCancelledSessions(req: any, res: any) {
562
+ const { error } = sessionIdsParamSchema.validate(req.body);
563
+ if (error) {
564
+ return res.status(400).json({ error: error.message });
565
+ }
566
+
567
+ const { sessionIds = [] } = req.body;
568
+
569
+ const allCheckoutSessions = await CheckoutSession.findAll({
570
+ where: { id: { [Op.in]: sessionIds } },
571
+ attributes: ['id', 'subscription_id', 'fulfillment_status', 'vendor_info'],
572
+ });
573
+
574
+ const subscriptionIds = allCheckoutSessions.map((item) => item.subscription_id!).filter((item) => !!item);
575
+
576
+ const cancelledSubscriptions = await Subscription.findAll({
577
+ where: {
578
+ id: { [Op.in]: subscriptionIds },
579
+ status: 'canceled',
580
+ },
581
+ attributes: ['id'],
582
+ });
583
+
584
+ const cancelledSubIds = cancelledSubscriptions.map((item) => item.id);
585
+ const cancelledSessions = allCheckoutSessions.filter(
586
+ (item) => item.subscription_id && cancelledSubIds.includes(item.subscription_id)
587
+ );
588
+ return res.json({ cancelledSessions });
589
+ }
590
+
564
591
  async function getVendorSubscription(req: any, res: any) {
565
592
  const { sessionId } = req.params;
566
593
 
@@ -636,6 +663,7 @@ router.get('/order/:sessionId/detail', loginAuth, validateParams(sessionIdParamS
636
663
 
637
664
  // Those for Vendor Call
638
665
  router.get('/connectTest', ensureVendorAuth, getVendorConnectTest);
666
+ router.post('/subscription/cancelled', ensureVendorAuth, getCancelledSessions);
639
667
  router.get('/subscription/:sessionId/redirect', handleSubscriptionRedirect);
640
668
  router.get('/subscription/:sessionId', ensureVendorAuth, getVendorSubscription);
641
669
 
@@ -239,6 +239,7 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
239
239
 
240
240
  attempts?: number;
241
241
  lastAttemptAt?: string;
242
+ returnRetry?: number;
242
243
  completedAt?: string;
243
244
  commissionAmount?: string;
244
245
 
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.15
17
+ version: 1.21.17
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.15",
3
+ "version": "1.21.17",
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.53-beta-20251011-054719-4ed2f6b7",
58
58
  "@blocklet/logger": "^1.16.53-beta-20251011-054719-4ed2f6b7",
59
- "@blocklet/payment-broker-client": "1.21.15",
60
- "@blocklet/payment-react": "1.21.15",
61
- "@blocklet/payment-vendor": "1.21.15",
59
+ "@blocklet/payment-broker-client": "1.21.17",
60
+ "@blocklet/payment-react": "1.21.17",
61
+ "@blocklet/payment-vendor": "1.21.17",
62
62
  "@blocklet/sdk": "^1.16.53-beta-20251011-054719-4ed2f6b7",
63
63
  "@blocklet/ui-react": "^3.1.46",
64
64
  "@blocklet/uploader": "^0.2.15",
@@ -128,7 +128,7 @@
128
128
  "devDependencies": {
129
129
  "@abtnode/types": "^1.16.53-beta-20251011-054719-4ed2f6b7",
130
130
  "@arcblock/eslint-config-ts": "^0.3.3",
131
- "@blocklet/payment-types": "1.21.15",
131
+ "@blocklet/payment-types": "1.21.17",
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": "fd48f9233f19514b537e30b013e09a7d9f7a9f48"
178
+ "gitHead": "a823bc05e706681ee70451437b0460aba909c253"
179
179
  }
@@ -65,6 +65,16 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
65
65
  <span style={composeStyles('gray')}>{formatTime(data.period_end * 1000)}</span>
66
66
  </div>
67
67
  </div>
68
+ <div style={composeStyles('flex mb-5')}>
69
+ <div style={composeStyles('w-40')}>
70
+ <span style={composeStyles('bold')}>{t('admin.paymentCurrency.name')}</span>
71
+ </div>
72
+ <div style={composeStyles('w-60')}>
73
+ <span style={composeStyles('gray')}>
74
+ {data.paymentCurrency.symbol} ({data.paymentMethod.name})
75
+ </span>
76
+ </div>
77
+ </div>
68
78
  </div>
69
79
  </div>
70
80
 
@@ -137,6 +147,26 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
137
147
  );
138
148
  })}
139
149
 
150
+ {detail.length === 0 && (
151
+ <div style={composeStyles('row flex')}>
152
+ <div style={composeStyles('w-38 p-4-8 pb-15')}>
153
+ <span style={composeStyles('gray')}>-</span>
154
+ </div>
155
+ <div style={composeStyles('w-15 p-4-8 pb-15')}>
156
+ <span style={composeStyles('gray right')}>-</span>
157
+ </div>
158
+ <div style={composeStyles('w-15 p-4-8 pb-15')}>
159
+ <span style={composeStyles('gray right')}>-</span>
160
+ </div>
161
+ <div style={composeStyles('w-15 p-4-8 pb-15')}>
162
+ <span style={composeStyles('gray right')}>-</span>
163
+ </div>
164
+ <div style={composeStyles('w-17 p-4-8 pb-15')}>
165
+ <span style={composeStyles('gray right')}>-</span>
166
+ </div>
167
+ </div>
168
+ )}
169
+
140
170
  {/* Summary */}
141
171
  <div
142
172
  style={{