payment-kit 1.26.4 → 1.26.5

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.
@@ -1539,6 +1539,83 @@ router.post('/:id/abort-stripe', user, ensureCheckoutSessionOpen, async (req, re
1539
1539
  }
1540
1540
  });
1541
1541
 
1542
+ // Skip payment method for $0 subscription — user chose "Skip, bind later"
1543
+ // Keeps the subscription but sets cancel_at_period_end so it won't renew without a payment method
1544
+ router.post('/:id/skip-payment-method', user, ensureCheckoutSessionOpen, async (req, res) => {
1545
+ try {
1546
+ if (!req.user) {
1547
+ return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
1548
+ }
1549
+
1550
+ const checkoutSession = req.doc as CheckoutSession;
1551
+
1552
+ if (!['subscription', 'setup'].includes(checkoutSession.mode)) {
1553
+ return res.status(400).json({ error: 'Skip payment method is only supported for subscriptions' });
1554
+ }
1555
+
1556
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
1557
+ const subscriptions = await Subscription.findAll({ where: { id: subscriptionIds } });
1558
+
1559
+ if (!subscriptions.length) {
1560
+ return res.status(400).json({ error: 'No subscriptions found for this checkout session' });
1561
+ }
1562
+
1563
+ // Cancel Stripe setup intents and activate subscriptions concurrently
1564
+ await Promise.all(
1565
+ subscriptions.map(async (sub) => {
1566
+ const stripeSubId = sub.payment_details?.stripe?.subscription_id;
1567
+ if (stripeSubId) {
1568
+ const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
1569
+ if (method?.type === 'stripe') {
1570
+ const client = method.getStripeClient();
1571
+ try {
1572
+ const stripeSub = await client.subscriptions.retrieve(stripeSubId, {
1573
+ expand: ['pending_setup_intent'],
1574
+ });
1575
+ if (stripeSub.pending_setup_intent && typeof stripeSub.pending_setup_intent !== 'string') {
1576
+ await client.setupIntents.cancel(stripeSub.pending_setup_intent.id);
1577
+ }
1578
+ await client.subscriptions.update(stripeSubId, { cancel_at_period_end: true });
1579
+ } catch (err: any) {
1580
+ logger.error('Failed to update Stripe subscription for skip-payment-method', {
1581
+ checkoutSessionId: checkoutSession.id,
1582
+ subscriptionId: sub.id,
1583
+ stripeSubId,
1584
+ error: err.message,
1585
+ });
1586
+ }
1587
+ }
1588
+ }
1589
+
1590
+ // Activate the local subscription with cancel_at_period_end
1591
+ await sub.update({
1592
+ status: sub.trial_end && sub.trial_end > Date.now() / 1000 ? 'trialing' : 'active',
1593
+ cancel_at_period_end: true,
1594
+ });
1595
+ await addSubscriptionJob(sub, 'cycle', false, sub.trial_end);
1596
+ })
1597
+ );
1598
+
1599
+ // Complete the checkout session
1600
+ await checkoutSession.update({
1601
+ status: 'complete',
1602
+ payment_status: 'no_payment_required',
1603
+ });
1604
+
1605
+ return res.json({
1606
+ checkoutSession: { id: checkoutSession.id, status: 'complete' },
1607
+ skipped: true,
1608
+ });
1609
+ } catch (err: any) {
1610
+ logger.error('Error in skip-payment-method', {
1611
+ sessionId: req.params.id,
1612
+ error: err.message,
1613
+ stack: err.stack,
1614
+ });
1615
+ res.status(500).json({ error: err.message });
1616
+ }
1617
+ });
1618
+
1542
1619
  // for checkout page
1543
1620
  router.get('/retrieve/:id', user, async (req, res) => {
1544
1621
  const doc = await CheckoutSession.findByPk(req.params.id);
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.26.4
17
+ version: 1.26.5
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.26.4",
3
+ "version": "1.26.5",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -59,9 +59,9 @@
59
59
  "@blocklet/error": "^0.3.5",
60
60
  "@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
61
61
  "@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
62
- "@blocklet/payment-broker-client": "1.26.4",
63
- "@blocklet/payment-react": "1.26.4",
64
- "@blocklet/payment-vendor": "1.26.4",
62
+ "@blocklet/payment-broker-client": "1.26.5",
63
+ "@blocklet/payment-react": "1.26.5",
64
+ "@blocklet/payment-vendor": "1.26.5",
65
65
  "@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
66
66
  "@blocklet/ui-react": "^3.5.1",
67
67
  "@blocklet/uploader": "^0.3.19",
@@ -132,7 +132,7 @@
132
132
  "devDependencies": {
133
133
  "@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
134
134
  "@arcblock/eslint-config-ts": "^0.3.3",
135
- "@blocklet/payment-types": "1.26.4",
135
+ "@blocklet/payment-types": "1.26.5",
136
136
  "@types/cookie-parser": "^1.4.9",
137
137
  "@types/cors": "^2.8.19",
138
138
  "@types/debug": "^4.1.12",
@@ -179,5 +179,5 @@
179
179
  "parser": "typescript"
180
180
  }
181
181
  },
182
- "gitHead": "90fb554f2edfcd1c141e3887eef835b1a545f5ad"
182
+ "gitHead": "2d208818d218494407989dd8cd460ab51d075952"
183
183
  }
@@ -82,6 +82,26 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
82
82
  setState({ loading: false, action: '' });
83
83
  }
84
84
  };
85
+ const onToggleSkipPaymentMethod = async () => {
86
+ const currentMetadata = (data.metadata || {}) as Record<string, any>;
87
+ const isEnabled = currentMetadata.allow_skip_payment_method === 'true';
88
+ try {
89
+ setState({ loading: true });
90
+ await api
91
+ .put(`/api/payment-links/${data.id}`, {
92
+ metadata: { ...currentMetadata, allow_skip_payment_method: isEnabled ? 'false' : 'true' },
93
+ })
94
+ .then((res: any) => res.data);
95
+ Toast.success(t('common.saved'));
96
+ onChange(state.action);
97
+ } catch (err) {
98
+ console.error(err);
99
+ Toast.error(formatError(err));
100
+ } finally {
101
+ setState({ loading: false, action: '' });
102
+ }
103
+ };
104
+
85
105
  const onCopyLink = () => {
86
106
  Copy(joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pay/${data.id}`));
87
107
  Toast.success(t('common.copied'));
@@ -127,6 +147,14 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
127
147
  handler: () => setState({ action: 'togglePromotionCodes' }),
128
148
  color: 'primary',
129
149
  },
150
+ {
151
+ label:
152
+ (data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
153
+ ? t('admin.paymentLink.disableSkipPaymentMethod')
154
+ : t('admin.paymentLink.enableSkipPaymentMethod'),
155
+ handler: () => setState({ action: 'toggleSkipPaymentMethod' }),
156
+ color: 'primary',
157
+ },
130
158
  {
131
159
  label: t('admin.passport.assign'),
132
160
  handler: () => setState({ action: 'assign' }),
@@ -162,6 +190,23 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
162
190
  loading={state.loading}
163
191
  />
164
192
  )}
193
+ {state.action === 'toggleSkipPaymentMethod' && (
194
+ <ConfirmDialog
195
+ onConfirm={onToggleSkipPaymentMethod}
196
+ onCancel={() => setState({ action: '' })}
197
+ title={
198
+ (data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
199
+ ? t('admin.paymentLink.disableSkipPaymentMethod')
200
+ : t('admin.paymentLink.enableSkipPaymentMethod')
201
+ }
202
+ message={
203
+ (data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
204
+ ? t('admin.paymentLink.disableSkipPaymentMethodTip')
205
+ : t('admin.paymentLink.enableSkipPaymentMethodTip')
206
+ }
207
+ loading={state.loading}
208
+ />
209
+ )}
165
210
  {state.action === 'togglePromotionCodes' && (
166
211
  <ConfirmDialog
167
212
  onConfirm={onTogglePromotionCodes}
@@ -279,6 +279,30 @@ export default function BeforePay({
279
279
  );
280
280
  }}
281
281
  />
282
+ <Controller
283
+ name="metadata"
284
+ control={control}
285
+ render={({ field }) => {
286
+ const metadata = field.value || {};
287
+ const isChecked = metadata.allow_skip_payment_method === 'true';
288
+
289
+ return (
290
+ <FormControlLabel
291
+ control={
292
+ <Checkbox
293
+ checked={isChecked}
294
+ onChange={(_, checked) => {
295
+ const newMetadata = { ...metadata };
296
+ newMetadata.allow_skip_payment_method = checked ? 'true' : 'false';
297
+ field.onChange(newMetadata);
298
+ }}
299
+ />
300
+ }
301
+ label={t('admin.paymentLink.allowSkipPaymentMethod')}
302
+ />
303
+ );
304
+ }}
305
+ />
282
306
  {includeFreeTrial && (
283
307
  <Controller
284
308
  name="subscription_data.trial_period_days"
@@ -133,12 +133,7 @@ export default function PaymentMethodInfo({
133
133
  setState({ editing: false, clientSecret: null, publishableKey: null, setupIntentId: null });
134
134
  };
135
135
 
136
- if (!paymentMethodDetails) {
137
- return null;
138
- }
139
-
140
- const { card, link, us_bank_account: usBankAccount, payer, type } = paymentMethodDetails;
141
-
136
+ // Stripe form for editing/binding — must be checked before early returns
142
137
  if (state.editing && state.clientSecret && state.publishableKey) {
143
138
  return (
144
139
  <Box>
@@ -156,6 +151,28 @@ export default function PaymentMethodInfo({
156
151
  );
157
152
  }
158
153
 
154
+ if (!paymentMethodDetails) {
155
+ if (!editable) {
156
+ return (
157
+ <Typography variant="body2" sx={{ color: 'text.secondary' }}>
158
+ {t('admin.subscription.noPaymentMethod', { defaultValue: 'Not bound' })}
159
+ </Typography>
160
+ );
161
+ }
162
+ return (
163
+ <Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
164
+ <Typography variant="body2" sx={{ color: 'text.secondary' }}>
165
+ {t('admin.subscription.noPaymentMethod', { defaultValue: 'Not bound' })}
166
+ </Typography>
167
+ <Button variant="text" size="small" sx={{ color: 'text.link' }} loading={state.submitting} onClick={handleEdit}>
168
+ {t('admin.subscription.bindPaymentMethod', { defaultValue: 'Bind now' })}
169
+ </Button>
170
+ </Stack>
171
+ );
172
+ }
173
+
174
+ const { card, link, us_bank_account: usBankAccount, payer, type } = paymentMethodDetails;
175
+
159
176
  const renderPaymentMethodInfo = () => {
160
177
  if (type === 'card' && card) {
161
178
  return (
@@ -534,6 +534,8 @@ export function SubscriptionActionsInner({
534
534
  e?.stopPropagation();
535
535
  if (action?.action === 'pastDue') {
536
536
  navigate(`/customer/invoice/past-due?subscription=${subscription.id}`);
537
+ } else if (action?.action === 'recover' && !(subscription as any).paymentMethodDetails) {
538
+ Toast.error(t('admin.subscription.bindPaymentMethodFirst'));
537
539
  } else {
538
540
  // @ts-ignore
539
541
  setState({ action: action?.action, subscription: subscription.id });
@@ -1080,6 +1080,12 @@ export default flat({
1080
1080
  includeFreeTrial: 'Include a free trial',
1081
1081
  noStakeRequired: 'No stake required',
1082
1082
  showProductFeatures: 'Show product features',
1083
+ allowSkipPaymentMethod: 'Allow skipping payment method for $0 checkout',
1084
+ enableSkipPaymentMethod: 'Allow skipping payment method',
1085
+ disableSkipPaymentMethod: 'Require payment method',
1086
+ enableSkipPaymentMethodTip:
1087
+ 'Allow customers to skip binding a payment method when the checkout amount is $0. The subscription will auto-cancel at the end of the current period if no payment method is added.',
1088
+ disableSkipPaymentMethodTip: 'Require customers to provide a payment method even when the checkout amount is $0.',
1083
1089
  freeTrialDaysPositive: 'Free trial days must be positive',
1084
1090
  includeCustomFields: 'Add custom fields',
1085
1091
  confirmPage: 'Confirmation Page',
@@ -1568,6 +1574,11 @@ export default flat({
1568
1574
  button: 'Pay due invoices',
1569
1575
  },
1570
1576
  payerAddress: 'Payment Address',
1577
+ noPaymentMethod: 'Not bound',
1578
+ bindPaymentMethod: 'Bind now',
1579
+ bindPaymentMethodFirst: 'Please bind a payment method first before resuming the subscription',
1580
+ noPaymentMethodWarning:
1581
+ 'No payment method is bound to this subscription. Please bind one to avoid cancellation at the end of the current period.',
1571
1582
  changePayer: {
1572
1583
  btn: 'Change',
1573
1584
  stripe: {
@@ -1041,6 +1041,12 @@ export default flat({
1041
1041
  includeFreeTrial: '包含免费试用',
1042
1042
  noStakeRequired: '无需质押',
1043
1043
  showProductFeatures: '显示产品特性',
1044
+ allowSkipPaymentMethod: '允许 $0 结账时跳过绑定支付方式',
1045
+ enableSkipPaymentMethod: '允许跳过支付方式',
1046
+ disableSkipPaymentMethod: '要求绑定支付方式',
1047
+ enableSkipPaymentMethodTip:
1048
+ '当结账金额为 $0 时,允许客户跳过绑定支付方式。若未绑定,订阅将在当前周期结束时自动取消。',
1049
+ disableSkipPaymentMethodTip: '即使结账金额为 $0,也要求客户提供支付方式。',
1044
1050
  freeTrialDaysPositive: '免费试用天数必须是正数',
1045
1051
  includeCustomFields: '添加自定义字段',
1046
1052
  requireCrossSell: '用户必须选择交叉销售的商品(如果有的话)',
@@ -1533,6 +1539,10 @@ export default flat({
1533
1539
  button: '批量付款',
1534
1540
  },
1535
1541
  payerAddress: '扣费地址',
1542
+ noPaymentMethod: '未绑定',
1543
+ bindPaymentMethod: '立即绑定',
1544
+ bindPaymentMethodFirst: '请先绑定支付方式后再恢复订阅',
1545
+ noPaymentMethodWarning: '当前订阅未绑定支付方式,请尽快绑定,否则订阅将在当前周期结束时自动取消。',
1536
1546
  changePayer: {
1537
1547
  btn: '变更',
1538
1548
  stripe: {
@@ -372,6 +372,14 @@ export default function PaymentLinkDetail(props: { id: string }) {
372
372
  value={data.phone_number_collection?.enabled ? t('common.yes') : t('common.no')}
373
373
  />
374
374
 
375
+ <InfoRow
376
+ label={t('admin.paymentLink.allowSkipPaymentMethod')}
377
+ value={
378
+ (data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
379
+ ? t('common.yes')
380
+ : t('common.no')
381
+ }
382
+ />
375
383
  <InfoRow
376
384
  label={t('admin.paymentLink.showConfirmPage')}
377
385
  value={renderConfirmPage(data.after_completion)}
@@ -454,6 +454,11 @@ export default function CustomerSubscriptionDetail() {
454
454
  <>
455
455
  <Root>
456
456
  <Box>
457
+ {!(data as any).paymentMethodDetails && ['active', 'trialing'].includes(data.status) && (
458
+ <Alert severity="warning" sx={{ mb: 2 }}>
459
+ {t('admin.subscription.noPaymentMethodWarning')}
460
+ </Alert>
461
+ )}
457
462
  {hasUnpaid && (
458
463
  <Alert severity="error" sx={{ mb: 2 }}>
459
464
  {t('customer.unpaidInvoicesWarningTip')}
@@ -820,24 +825,22 @@ export default function CustomerSubscriptionDetail() {
820
825
  }
821
826
  />
822
827
  )}
823
- {(data as any).paymentMethodDetails && (
824
- <InfoRow
825
- label={t('admin.subscription.payerAddress')}
826
- value={
827
- <PaymentMethodInfo
828
- subscriptionId={id}
829
- customer={data.customer}
830
- paymentMethodDetails={(data as any).paymentMethodDetails}
831
- editable={['active', 'trialing', 'past_due'].includes(data.status)}
832
- onUpdate={() => {
833
- refresh();
834
- checkUnpaidInvoices();
835
- }}
836
- paymentMethodType={data.paymentMethod?.type}
837
- />
838
- }
839
- />
840
- )}
828
+ <InfoRow
829
+ label={t('admin.subscription.payerAddress')}
830
+ value={
831
+ <PaymentMethodInfo
832
+ subscriptionId={id}
833
+ customer={data.customer}
834
+ paymentMethodDetails={(data as any).paymentMethodDetails}
835
+ editable={['active', 'trialing', 'past_due'].includes(data.status)}
836
+ onUpdate={() => {
837
+ refresh();
838
+ checkUnpaidInvoices();
839
+ }}
840
+ paymentMethodType={data.paymentMethod?.type}
841
+ />
842
+ }
843
+ />
841
844
 
842
845
  {data.payment_details && hasDelegateTxHash(data.payment_details, data.paymentMethod) && (
843
846
  <InfoRow