payment-kit 1.18.29 → 1.18.31

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 (56) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/crons/metering-subscription-detection.ts +9 -0
  3. package/api/src/integrations/arcblock/nft.ts +1 -0
  4. package/api/src/integrations/blocklet/passport.ts +1 -1
  5. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
  7. package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
  8. package/api/src/integrations/stripe/resource.ts +81 -1
  9. package/api/src/libs/audit.ts +42 -0
  10. package/api/src/libs/constants.ts +2 -0
  11. package/api/src/libs/env.ts +2 -2
  12. package/api/src/libs/invoice.ts +54 -7
  13. package/api/src/libs/notification/index.ts +72 -4
  14. package/api/src/libs/notification/template/base.ts +2 -0
  15. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
  16. package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
  17. package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
  18. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
  19. package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
  20. package/api/src/libs/payment.ts +48 -8
  21. package/api/src/libs/product.ts +1 -4
  22. package/api/src/libs/session.ts +600 -8
  23. package/api/src/libs/setting.ts +172 -0
  24. package/api/src/libs/subscription.ts +7 -69
  25. package/api/src/libs/ws.ts +5 -0
  26. package/api/src/queues/checkout-session.ts +42 -36
  27. package/api/src/queues/notification.ts +3 -2
  28. package/api/src/queues/payment.ts +56 -8
  29. package/api/src/queues/usage-record.ts +2 -10
  30. package/api/src/routes/checkout-sessions.ts +324 -187
  31. package/api/src/routes/connect/shared.ts +160 -38
  32. package/api/src/routes/connect/subscribe.ts +123 -64
  33. package/api/src/routes/payment-currencies.ts +11 -0
  34. package/api/src/routes/payment-links.ts +11 -1
  35. package/api/src/routes/payment-stats.ts +2 -2
  36. package/api/src/routes/payouts.ts +2 -1
  37. package/api/src/routes/settings.ts +45 -0
  38. package/api/src/routes/subscriptions.ts +1 -2
  39. package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
  40. package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
  41. package/api/src/store/models/checkout-session.ts +52 -0
  42. package/api/src/store/models/index.ts +1 -0
  43. package/api/src/store/models/payment-link.ts +6 -0
  44. package/api/src/store/models/subscription.ts +8 -6
  45. package/api/src/store/models/types.ts +32 -1
  46. package/api/tests/libs/session.spec.ts +423 -0
  47. package/api/tests/libs/subscription.spec.ts +0 -110
  48. package/blocklet.yml +3 -1
  49. package/package.json +25 -24
  50. package/scripts/sdk.js +486 -155
  51. package/src/locales/en.tsx +4 -0
  52. package/src/locales/zh.tsx +3 -0
  53. package/src/pages/admin/settings/vault-config/edit-form.tsx +58 -3
  54. package/src/pages/admin/settings/vault-config/index.tsx +35 -1
  55. package/src/pages/customer/subscription/change-payment.tsx +8 -3
  56. package/src/pages/integrations/overview.tsx +1 -1
@@ -8,9 +8,9 @@ import { getFastCheckoutAmount } from '../../libs/session';
8
8
  import { getTxMetadata } from '../../libs/util';
9
9
  import { invoiceQueue } from '../../queues/invoice';
10
10
  import { addSubscriptionJob } from '../../queues/subscription';
11
- import type { Invoice, TLineItemExpanded } from '../../store/models';
11
+ import type { Invoice, Subscription, TLineItemExpanded } from '../../store/models';
12
12
  import {
13
- ensureInvoiceForCheckout,
13
+ ensureInvoicesForSubscriptions,
14
14
  ensurePaymentIntent,
15
15
  executeOcapTransactions,
16
16
  getAuthPrincipalClaim,
@@ -20,6 +20,14 @@ import {
20
20
  import { ensureStakeInvoice } from '../../libs/invoice';
21
21
  import { EVM_CHAIN_TYPES } from '../../libs/constants';
22
22
 
23
+ const updateInvoices = async (invoices: Invoice[], update: Partial<Invoice>) => {
24
+ await Promise.all(invoices.map((invoice) => invoice.update(update)));
25
+ };
26
+
27
+ const updateSubscriptions = async (subscriptions: Subscription[], update: Partial<Subscription>) => {
28
+ await Promise.all(subscriptions.map((subscription) => subscription.update(update)));
29
+ };
30
+
23
31
  export default {
24
32
  action: 'subscription',
25
33
  authPrincipal: false,
@@ -33,12 +41,16 @@ export default {
33
41
  onConnect: async (args: CallbackArgs) => {
34
42
  const { userDid, userPk, extraParams } = args;
35
43
  const { checkoutSessionId, connectedDid, sessionUserDid } = extraParams;
36
- const { checkoutSession, paymentMethod, paymentCurrency, subscription, customer } = await ensurePaymentIntent(
37
- checkoutSessionId,
38
- connectedDid || sessionUserDid || userDid
39
- );
40
- if (!subscription) {
41
- throw new Error('Subscription for checkoutSession not found');
44
+ const {
45
+ checkoutSession,
46
+ paymentMethod,
47
+ paymentCurrency,
48
+ subscriptions,
49
+ customer,
50
+ subscription: primarySubscription,
51
+ } = await ensurePaymentIntent(checkoutSessionId, connectedDid || sessionUserDid || userDid);
52
+ if (!subscriptions || subscriptions.length === 0) {
53
+ throw new Error('No subscriptions found for checkoutSession');
42
54
  }
43
55
 
44
56
  const now = dayjs().unix();
@@ -52,6 +64,8 @@ export default {
52
64
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
53
65
  const fastCheckoutAmount = getFastCheckoutAmount(items, checkoutSession.mode, paymentCurrency.id, trialing);
54
66
  const claimsList: any[] = [];
67
+
68
+ const allSubscriptionIds = subscriptions.map((sub) => sub.id);
55
69
  if (paymentMethod.type === 'arcblock') {
56
70
  const delegation = await isDelegationSufficientForPayment({
57
71
  paymentMethod,
@@ -61,14 +75,19 @@ export default {
61
75
  });
62
76
 
63
77
  // if we can complete purchase without any wallet interaction
64
- if (delegation.sufficient === false) {
78
+ // we forced to delegate if we can skip stake
79
+ if (delegation.sufficient === false || checkoutSession.subscription_data?.no_stake) {
65
80
  claimsList.push({
66
81
  signature: await getDelegationTxClaim({
67
82
  mode: checkoutSession.mode,
68
83
  userDid,
69
84
  userPk,
70
85
  nonce: checkoutSession.id,
71
- data: getTxMetadata({ subscriptionId: subscription.id, checkoutSessionId }),
86
+ data: getTxMetadata({
87
+ subscriptionId: primarySubscription?.id,
88
+ subscriptionIds: allSubscriptionIds,
89
+ checkoutSessionId,
90
+ }),
72
91
  paymentCurrency,
73
92
  paymentMethod,
74
93
  trialing,
@@ -78,17 +97,19 @@ export default {
78
97
  });
79
98
  }
80
99
 
81
- // we always need to stake for the subscription
82
- claimsList.push({
83
- prepareTx: await getStakeTxClaim({
84
- userDid,
85
- userPk,
86
- paymentCurrency,
87
- paymentMethod,
88
- items,
89
- subscription,
90
- }),
91
- });
100
+ if (!checkoutSession.subscription_data?.no_stake) {
101
+ claimsList.push({
102
+ prepareTx: await getStakeTxClaim({
103
+ userDid,
104
+ userPk,
105
+ paymentCurrency,
106
+ paymentMethod,
107
+ items,
108
+ subscription: primarySubscription as Subscription,
109
+ subscriptions,
110
+ }),
111
+ });
112
+ }
92
113
 
93
114
  return claimsList;
94
115
  }
@@ -104,7 +125,11 @@ export default {
104
125
  userDid,
105
126
  userPk,
106
127
  nonce: checkoutSession.id,
107
- data: getTxMetadata({ subscriptionId: subscription.id, checkoutSessionId }),
128
+ data: getTxMetadata({
129
+ subscriptionId: primarySubscription?.id,
130
+ subscriptionIds: allSubscriptionIds,
131
+ checkoutSessionId,
132
+ }),
108
133
  paymentCurrency,
109
134
  paymentMethod,
110
135
  trialing,
@@ -121,7 +146,7 @@ export default {
121
146
  onAuth: async (args: CallbackArgs) => {
122
147
  const { request, userDid, userPk, claims, extraParams, updateSession, step } = args;
123
148
  const { checkoutSessionId, connectedDid, sessionUserDid } = extraParams;
124
- const { checkoutSession, customer, paymentMethod, subscription, paymentCurrency } = await ensurePaymentIntent(
149
+ const { checkoutSession, customer, paymentMethod, subscriptions, paymentCurrency } = await ensurePaymentIntent(
125
150
  checkoutSessionId,
126
151
  connectedDid || sessionUserDid || userDid
127
152
  );
@@ -134,7 +159,9 @@ export default {
134
159
  },
135
160
  });
136
161
  const staking = result.find((x: any) => x.claim?.type === 'prepareTx' && x.claim?.meta?.purpose === 'staking');
137
- const isFinalStep = (paymentMethod.type === 'arcblock' && staking) || paymentMethod.type !== 'arcblock';
162
+ const isFinalStep =
163
+ (paymentMethod.type === 'arcblock' && (staking || checkoutSession.subscription_data?.no_stake)) ||
164
+ paymentMethod.type !== 'arcblock';
138
165
  if (!isFinalStep) {
139
166
  await updateSession({
140
167
  result,
@@ -151,10 +178,11 @@ export default {
151
178
  });
152
179
  }
153
180
  const claimsList = result.map((x: any) => x.claim);
154
-
155
- if (!subscription) {
156
- throw new Error('Subscription for checkoutSession not found');
181
+ if (!subscriptions || subscriptions.length === 0) {
182
+ throw new Error('No subscriptions found for checkoutSession');
157
183
  }
184
+ const primarySubscription = subscriptions[0] as Subscription;
185
+
158
186
  const paymentSettings = {
159
187
  payment_method_types: [paymentMethod.type],
160
188
  payment_method_options: {
@@ -162,31 +190,44 @@ export default {
162
190
  },
163
191
  };
164
192
  const prepareTxExecution = async () => {
165
- await subscription.update({
166
- payment_settings: paymentSettings,
167
- });
193
+ await updateSubscriptions(subscriptions, { payment_settings: paymentSettings });
168
194
  };
169
195
 
170
- const afterTxExecution = async (invoice: Invoice, paymentDetails: any) => {
171
- await subscription.update({ payment_details: { [paymentMethod.type]: paymentDetails } });
172
- if (invoice) {
173
- invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
196
+ const afterTxExecution = async (invoices: Invoice[], paymentDetails: Record<string, any>) => {
197
+ await updateSubscriptions(subscriptions, { payment_details: { [paymentMethod.type]: paymentDetails } });
198
+
199
+ for (const invoice of invoices) {
200
+ if (invoice) {
201
+ invoiceQueue.pushAndWait({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
202
+ }
174
203
  }
175
- await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
176
- logger.info('CheckoutSession updated on subscription done', {
204
+
205
+ await Promise.all(
206
+ subscriptions.map((subscription) => addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end))
207
+ );
208
+
209
+ logger.info('CheckoutSession updated with multiple subscriptions', {
177
210
  checkoutSession: checkoutSession.id,
178
- subscription: subscription.id,
211
+ subscriptionIds: subscriptions.map((s) => s.id),
179
212
  paymentDetails,
180
213
  });
181
214
  };
182
215
 
183
216
  if (paymentMethod.type === 'arcblock') {
184
217
  await prepareTxExecution();
185
- const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
186
- if (invoice) {
187
- await invoice.update({ payment_settings: paymentSettings });
218
+
219
+ const { invoices } = await ensureInvoicesForSubscriptions({
220
+ checkoutSession,
221
+ customer,
222
+ subscriptions,
223
+ });
224
+
225
+ if (invoices.length === 0) {
226
+ throw new Error('No invoices found for subscriptions');
188
227
  }
189
228
 
229
+ await updateInvoices(invoices, { payment_settings: paymentSettings });
230
+
190
231
  const requestArray = result
191
232
  .map((item: { stepRequest?: Request }) => item.stepRequest)
192
233
  .filter(Boolean) as Request[];
@@ -199,40 +240,58 @@ export default {
199
240
  claimsList,
200
241
  paymentMethod,
201
242
  requestSource,
202
- subscription?.id,
203
- paymentCurrency?.contract
243
+ primarySubscription?.id, // use the primary subscription id
244
+ paymentCurrency?.contract,
245
+ subscriptions.map((s) => s.id).join('-') // use all subscription ids as nonce
204
246
  );
205
- await ensureStakeInvoice(
206
- {
207
- total: stakingAmount,
208
- description: 'Stake for subscription',
209
- checkout_session_id: checkoutSessionId,
210
- currency_id: paymentCurrency.id,
211
- metadata: {
212
- payment_details: {
213
- arcblock: {
214
- tx_hash: paymentDetails?.staking?.tx_hash,
215
- payer: paymentDetails?.payer,
216
- address: paymentDetails?.staking?.address,
247
+
248
+ // create a stake invoice
249
+ if (stakingAmount && stakingAmount !== '0') {
250
+ await ensureStakeInvoice(
251
+ {
252
+ total: stakingAmount,
253
+ description: 'Stake for subscription',
254
+ checkout_session_id: checkoutSessionId,
255
+ currency_id: paymentCurrency.id,
256
+ metadata: {
257
+ payment_details: {
258
+ arcblock: {
259
+ tx_hash: paymentDetails?.staking?.tx_hash,
260
+ payer: paymentDetails?.payer,
261
+ address: paymentDetails?.staking?.address,
262
+ },
217
263
  },
264
+ subscription_ids: subscriptions.map((s) => s.id),
265
+ is_group_stake: subscriptions.length > 1,
218
266
  },
219
267
  },
220
- },
221
- subscription,
222
- paymentMethod,
223
- customer
224
- );
225
- await afterTxExecution(invoice!, paymentDetails);
268
+ primarySubscription,
269
+ paymentMethod,
270
+ customer,
271
+ subscriptions
272
+ );
273
+ }
274
+
275
+ await afterTxExecution(invoices, paymentDetails);
226
276
 
227
277
  return { hash: paymentDetails.tx_hash };
228
278
  }
229
279
 
230
280
  if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
231
281
  await prepareTxExecution();
232
- const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
233
- if (invoice) {
234
- await invoice.update({ payment_settings: paymentSettings });
282
+
283
+ const { invoices } = await ensureInvoicesForSubscriptions({
284
+ checkoutSession,
285
+ customer,
286
+ subscriptions,
287
+ });
288
+
289
+ if (invoices.length === 0) {
290
+ throw new Error('No invoices found for subscriptions');
235
291
  }
292
+
293
+ await updateInvoices(invoices, { payment_settings: paymentSettings });
294
+
236
295
  broadcastEvmTransaction(checkoutSessionId, 'pending', claimsList);
237
296
 
238
297
  const paymentDetails = await executeEvmTransaction('approve', userDid, claimsList, paymentMethod);
@@ -242,7 +301,7 @@ export default {
242
301
  paymentMethod.confirmation.block
243
302
  )
244
303
  .then(async () => {
245
- await afterTxExecution(invoice!, paymentDetails);
304
+ await afterTxExecution(invoices, paymentDetails);
246
305
  broadcastEvmTransaction(checkoutSessionId, 'confirmed', claimsList);
247
306
  })
248
307
  .catch(console.error);
@@ -215,6 +215,10 @@ router.put('/:id/deposit-vault', auth, async (req, res) => {
215
215
  if (!paymentCurrency) {
216
216
  return res.status(404).json({ error: 'Payment currency not found' });
217
217
  }
218
+ const vaultAddress = await getVaultAddress();
219
+ if (!vaultAddress) {
220
+ return res.status(400).json({ error: 'Vault address not found' });
221
+ }
218
222
  depositVaultQueue.push({
219
223
  id: `deposit-vault-${paymentCurrency.id}`,
220
224
  job: { currencyId: paymentCurrency.id },
@@ -239,6 +243,7 @@ const UpdateVaultConfigSchema = Joi.object({
239
243
  enabled: Joi.boolean().required(),
240
244
  deposit_threshold: Joi.number().greater(0).required(),
241
245
  withdraw_threshold: Joi.number().min(0).required(),
246
+ buffer_threshold: Joi.number().min(0).required(),
242
247
  });
243
248
  router.put('/:id/vault-config', authOwner, async (req, res) => {
244
249
  try {
@@ -254,11 +259,17 @@ router.put('/:id/vault-config', authOwner, async (req, res) => {
254
259
  return res.status(404).json({ error: 'payment currency not found' });
255
260
  }
256
261
 
262
+ const vaultAddress = await getVaultAddress();
263
+ if (!vaultAddress) {
264
+ return res.status(400).json({ error: 'Vault address not found' });
265
+ }
266
+
257
267
  const updateData: Partial<TPaymentCurrency> = {
258
268
  vault_config: {
259
269
  enabled: vaultConfig.enabled,
260
270
  deposit_threshold: fromTokenToUnit(vaultConfig.deposit_threshold, paymentCurrency.decimal).toString(),
261
271
  withdraw_threshold: fromTokenToUnit(vaultConfig.withdraw_threshold, paymentCurrency.decimal).toString(),
272
+ buffer_threshold: fromTokenToUnit(vaultConfig.buffer_threshold || 0, paymentCurrency.decimal).toString(),
262
273
  },
263
274
  };
264
275
 
@@ -51,6 +51,7 @@ const formatBeforeSave = (payload: any) => {
51
51
  cross_sell_behavior: 'auto',
52
52
  payment_intent_data: null,
53
53
  donation_settings: null,
54
+ enable_subscription_grouping: false,
54
55
  },
55
56
  pick(payload, [
56
57
  'name',
@@ -71,8 +72,15 @@ const formatBeforeSave = (payload: any) => {
71
72
  'cross_sell_behavior',
72
73
  'donation_settings',
73
74
  'metadata',
75
+ 'enable_subscription_grouping',
74
76
  ])
75
77
  );
78
+
79
+ // TODO: need to support stake subscription
80
+ if (raw.enable_subscription_grouping === true && !raw.subscription_data?.no_stake) {
81
+ throw new Error('Subscription grouping is only supported for stake-free subscriptions');
82
+ }
83
+
76
84
  if (raw.after_completion?.type === 'hosted_confirmation') {
77
85
  // @ts-ignore
78
86
  raw.after_completion.redirect = null;
@@ -83,7 +91,7 @@ const formatBeforeSave = (payload: any) => {
83
91
  }
84
92
  if (typeof payload.include_free_trial === 'boolean' && !payload.include_free_trial) {
85
93
  // @ts-ignore
86
- raw.subscription_data = null;
94
+ raw.subscription_data.trial_period_days = 0;
87
95
  }
88
96
 
89
97
  if (raw.nft_mint_settings?.enabled) {
@@ -181,6 +189,7 @@ const PaymentLinkCreateSchema = Joi.object({
181
189
  .min(0)
182
190
  .optional(),
183
191
  allow_promotion_codes: Joi.boolean().optional(),
192
+ enable_subscription_grouping: Joi.boolean().optional(),
184
193
  nft_mint_settings: Joi.object({
185
194
  enabled: Joi.boolean().required(),
186
195
  factory: Joi.string().max(40).empty('').optional(),
@@ -310,6 +319,7 @@ const PaymentLinkUpdateSchema = Joi.object({
310
319
  .min(0)
311
320
  .optional(),
312
321
  allow_promotion_codes: Joi.boolean().optional(),
322
+ enable_subscription_grouping: Joi.boolean().optional(),
313
323
  nft_mint_settings: Joi.object({
314
324
  enabled: Joi.boolean().required(),
315
325
  factory: Joi.string().max(40).empty('').optional(),
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import Joi from 'joi';
3
- import { Op } from 'sequelize';
3
+ import { Op, type WhereOptions } from 'sequelize';
4
4
  import { joinURL } from 'ufo';
5
5
 
6
6
  import { getPaymentStat } from '../crons/payment-stat';
@@ -33,7 +33,7 @@ const schema = createListParamSchema<{ currency_id?: string; start?: number; end
33
33
  });
34
34
  router.get('/', auth, async (req, res) => {
35
35
  const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
36
- const where: any = {};
36
+ const where: WhereOptions = {};
37
37
 
38
38
  if (typeof query.livemode === 'boolean') {
39
39
  where.livemode = query.livemode;
@@ -4,6 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
 
6
6
  import sessionMiddleware from '@blocklet/sdk/lib/middlewares/session';
7
+ import type { WhereOptions } from 'sequelize';
7
8
  import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
8
9
  import { authenticate } from '../libs/security';
9
10
  import { formatMetadata } from '../libs/util';
@@ -136,7 +137,7 @@ router.get('/mine', sessionMiddleware(), async (req, res) => {
136
137
  throw new Error(`Customer not found: ${req.user?.did}`);
137
138
  }
138
139
 
139
- const where: any = { customer_id: customer.id };
140
+ const where: WhereOptions = { customer_id: customer.id };
140
141
  if (currencyId) {
141
142
  where.currency_id = currencyId;
142
143
  }
@@ -171,6 +171,18 @@ router.post('/', async (req, res) => {
171
171
  raw.settings.amount = settings.amount;
172
172
  }
173
173
  }
174
+ if (type === 'notification') {
175
+ const notificationSchema = Joi.object({
176
+ self_handle: Joi.boolean().required().default(false),
177
+ include_events: Joi.array().items(Joi.string()).optional(),
178
+ exclude_events: Joi.array().items(Joi.string()).optional(),
179
+ });
180
+ const { error: notificationError, value: notificationSettings } = notificationSchema.validate(settings);
181
+ if (notificationError) {
182
+ return res.status(400).json({ error: notificationError.message });
183
+ }
184
+ raw.settings = notificationSettings;
185
+ }
174
186
  const exist = await Setting.findOne({
175
187
  where: {
176
188
  type,
@@ -251,6 +263,18 @@ router.put('/:mountLocationOrId', authAdmin, async (req, res) => {
251
263
  raw.settings.amount = settings.amount;
252
264
  }
253
265
  }
266
+ if (setting.type === 'notification') {
267
+ const notificationSchema = Joi.object({
268
+ self_handle: Joi.boolean().required().default(false),
269
+ include_events: Joi.array().items(Joi.string()).optional(),
270
+ exclude_events: Joi.array().items(Joi.string()).optional(),
271
+ });
272
+ const { error: notificationError, value: notificationSettings } = notificationSchema.validate(settings);
273
+ if (notificationError) {
274
+ return res.status(400).json({ error: notificationError.message });
275
+ }
276
+ raw.settings = notificationSettings;
277
+ }
254
278
  const doc = await setting.update(raw);
255
279
  return res.json(doc);
256
280
  } catch (err) {
@@ -284,4 +308,25 @@ router.delete('/:mountLocationOrId', authAdmin, async (req, res) => {
284
308
  }
285
309
  });
286
310
 
311
+ router.get('/:mountLocationOrId', authAdmin, async (req, res) => {
312
+ try {
313
+ const setting = await Setting.findOne({
314
+ where: {
315
+ [Op.or]: [
316
+ {
317
+ id: req.params.mountLocationOrId,
318
+ },
319
+ {
320
+ mount_location: req.params.mountLocationOrId,
321
+ },
322
+ ],
323
+ },
324
+ });
325
+ return res.json(setting);
326
+ } catch (err) {
327
+ logger.error(err);
328
+ return res.status(400).json({ error: err.message });
329
+ }
330
+ });
331
+
287
332
  export default router;
@@ -15,12 +15,11 @@ import dayjs from '../libs/dayjs';
15
15
  import logger from '../libs/logger';
16
16
  import { isDelegationSufficientForPayment } from '../libs/payment';
17
17
  import { authenticate } from '../libs/security';
18
- import { expandLineItems, getFastCheckoutAmount, isLineItemAligned } from '../libs/session';
18
+ import { expandLineItems, getFastCheckoutAmount, getSubscriptionCreateSetup, isLineItemAligned } from '../libs/session';
19
19
  import {
20
20
  createProration,
21
21
  finalizeSubscriptionUpdate,
22
22
  getPastInvoicesAmount,
23
- getSubscriptionCreateSetup,
24
23
  getSubscriptionPaymentAddress,
25
24
  getSubscriptionRefundSetup,
26
25
  getSubscriptionStakeReturnSetup,
@@ -0,0 +1,39 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { Migration, safeApplyColumnChanges } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await safeApplyColumnChanges(context, {
7
+ checkout_sessions: [
8
+ {
9
+ name: 'enable_subscription_grouping',
10
+ field: {
11
+ type: DataTypes.BOOLEAN,
12
+ defaultValue: false,
13
+ },
14
+ },
15
+ {
16
+ name: 'subscription_groups',
17
+ field: {
18
+ type: DataTypes.JSON,
19
+ allowNull: true,
20
+ },
21
+ },
22
+ ],
23
+ payment_links: [
24
+ {
25
+ name: 'enable_subscription_grouping',
26
+ field: {
27
+ type: DataTypes.BOOLEAN,
28
+ defaultValue: false,
29
+ },
30
+ },
31
+ ],
32
+ });
33
+ };
34
+
35
+ export const down: Migration = async ({ context }) => {
36
+ await context.removeColumn('checkout_sessions', 'enable_subscription_grouping');
37
+ await context.removeColumn('checkout_sessions', 'subscription_groups');
38
+ await context.removeColumn('payment_links', 'enable_subscription_grouping');
39
+ };
@@ -0,0 +1,69 @@
1
+ import { DataTypes, QueryTypes } from 'sequelize';
2
+ import { Migration, safeApplyColumnChanges } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ try {
6
+ // 添加 success_subscription_count 字段
7
+ await safeApplyColumnChanges(context, {
8
+ checkout_sessions: [
9
+ {
10
+ name: 'success_subscription_count',
11
+ field: {
12
+ type: DataTypes.INTEGER,
13
+ defaultValue: 0,
14
+ },
15
+ },
16
+ ],
17
+ });
18
+
19
+ const sessions = (await context.sequelize.query(
20
+ `SELECT id, subscription_id, subscription_groups
21
+ FROM checkout_sessions
22
+ WHERE payment_status = 'paid' AND (mode = 'subscription' OR mode = 'setup')`,
23
+ { type: QueryTypes.SELECT }
24
+ )) as any[];
25
+
26
+ if (sessions.length > 0) {
27
+ // 收集更新数据
28
+ const updates: { id: string; count: number }[] = [];
29
+ for (const session of sessions) {
30
+ let count = 0;
31
+ if (session.subscription_groups) {
32
+ try {
33
+ const groups = JSON.parse(session.subscription_groups);
34
+ count = Object.keys(groups).length;
35
+ } catch (e) {
36
+ console.warn(`Failed to parse subscription_groups for session ${session.id}`);
37
+ }
38
+ }
39
+
40
+ if (count === 0 && session.subscription_id) {
41
+ count = 1;
42
+ }
43
+
44
+ if (count > 0) {
45
+ updates.push({ id: session.id, count });
46
+ }
47
+ }
48
+
49
+ // 使用事务确保原子性
50
+ await context.sequelize.transaction(async (t) => {
51
+ for (const update of updates) {
52
+ // eslint-disable-next-line no-await-in-loop
53
+ await context.sequelize.query('UPDATE checkout_sessions SET success_subscription_count = ? WHERE id = ?', {
54
+ replacements: [update.count, update.id],
55
+ transaction: t,
56
+ type: QueryTypes.UPDATE,
57
+ });
58
+ }
59
+ });
60
+ }
61
+ } catch (error) {
62
+ console.error('Migration failed:', error);
63
+ throw error;
64
+ }
65
+ };
66
+
67
+ export const down: Migration = async ({ context }) => {
68
+ await context.removeColumn('checkout_sessions', 'success_subscription_count');
69
+ };