payment-kit 1.18.30 → 1.18.32

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 (51) hide show
  1. package/api/src/crons/metering-subscription-detection.ts +9 -0
  2. package/api/src/integrations/arcblock/nft.ts +1 -0
  3. package/api/src/integrations/blocklet/passport.ts +1 -1
  4. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  5. package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
  6. package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
  7. package/api/src/integrations/stripe/resource.ts +81 -1
  8. package/api/src/libs/audit.ts +42 -0
  9. package/api/src/libs/invoice.ts +54 -7
  10. package/api/src/libs/notification/index.ts +72 -4
  11. package/api/src/libs/notification/template/base.ts +2 -0
  12. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
  13. package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
  14. package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
  15. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
  17. package/api/src/libs/payment.ts +47 -14
  18. package/api/src/libs/product.ts +1 -4
  19. package/api/src/libs/session.ts +600 -8
  20. package/api/src/libs/setting.ts +172 -0
  21. package/api/src/libs/subscription.ts +7 -69
  22. package/api/src/libs/ws.ts +5 -0
  23. package/api/src/queues/checkout-session.ts +42 -36
  24. package/api/src/queues/notification.ts +3 -2
  25. package/api/src/queues/payment.ts +33 -6
  26. package/api/src/queues/usage-record.ts +2 -10
  27. package/api/src/routes/checkout-sessions.ts +324 -187
  28. package/api/src/routes/connect/shared.ts +160 -38
  29. package/api/src/routes/connect/subscribe.ts +123 -64
  30. package/api/src/routes/payment-currencies.ts +3 -6
  31. package/api/src/routes/payment-links.ts +11 -1
  32. package/api/src/routes/payment-stats.ts +2 -2
  33. package/api/src/routes/payouts.ts +2 -1
  34. package/api/src/routes/settings.ts +45 -0
  35. package/api/src/routes/subscriptions.ts +1 -2
  36. package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
  37. package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
  38. package/api/src/store/models/checkout-session.ts +52 -0
  39. package/api/src/store/models/index.ts +1 -0
  40. package/api/src/store/models/payment-link.ts +6 -0
  41. package/api/src/store/models/subscription.ts +8 -6
  42. package/api/src/store/models/types.ts +31 -1
  43. package/api/tests/libs/session.spec.ts +423 -0
  44. package/api/tests/libs/subscription.spec.ts +0 -110
  45. package/blocklet.yml +3 -1
  46. package/package.json +20 -19
  47. package/scripts/sdk.js +486 -155
  48. package/src/locales/en.tsx +1 -1
  49. package/src/locales/zh.tsx +1 -1
  50. package/src/pages/admin/settings/vault-config/edit-form.tsx +1 -1
  51. package/src/pages/customer/subscription/change-payment.tsx +8 -3
@@ -7,23 +7,30 @@ import { BN, fromTokenToUnit, toBase58 } from '@ocap/util';
7
7
  import { fromPublicKey } from '@ocap/wallet';
8
8
  import type { Request } from 'express';
9
9
  import { isEmpty } from 'lodash';
10
-
10
+ import dayjs from '../../libs/dayjs';
11
11
  import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/arcblock/stake';
12
12
  import { encodeApproveItx } from '../../integrations/ethereum/token';
13
13
  import { blocklet, ethWallet, wallet } from '../../libs/auth';
14
14
  import logger from '../../libs/logger';
15
15
  import { getGasPayerExtra, getTokenLimitsForDelegation } from '../../libs/payment';
16
- import { getFastCheckoutAmount, getStatementDescriptor, isDonationCheckoutSession } from '../../libs/session';
16
+ import {
17
+ getCheckoutAmount,
18
+ getCheckoutSessionSubscriptionIds,
19
+ getFastCheckoutAmount,
20
+ getStatementDescriptor,
21
+ getSubscriptionCreateSetup,
22
+ isDonationCheckoutSession,
23
+ getSubscriptionLineItems
24
+ } from '../../libs/session';
17
25
  import {
18
26
  expandSubscriptionItems,
19
- getSubscriptionCreateSetup,
20
27
  getSubscriptionPaymentAddress,
21
28
  getSubscriptionStakeSetup,
22
29
  } from '../../libs/subscription';
23
30
  import { getCustomerStakeAddress, OCAP_PAYMENT_TX_TYPE } from '../../libs/util';
24
31
 
25
32
  import { invoiceQueue } from '../../queues/invoice';
26
- import type { TLineItemExpanded } from '../../store/models';
33
+ import { type TLineItemExpanded } from '../../store/models';
27
34
  import { CheckoutSession } from '../../store/models/checkout-session';
28
35
  import { Customer } from '../../store/models/customer';
29
36
  import { Invoice, TInvoice } from '../../store/models/invoice';
@@ -42,6 +49,7 @@ type Result = {
42
49
  customer: Customer;
43
50
  paymentIntent?: PaymentIntent;
44
51
  subscription?: Subscription;
52
+ subscriptions?: Subscription[];
45
53
  paymentCurrency: PaymentCurrency;
46
54
  paymentMethod: PaymentMethod;
47
55
  invoice?: Invoice;
@@ -85,20 +93,32 @@ export async function ensurePaymentIntent(checkoutSessionId: string, userDid?: s
85
93
  paymentMethodId = paymentIntent.payment_method_id;
86
94
  }
87
95
 
88
- let subscription;
89
- if (checkoutSession.subscription_id) {
90
- subscription = await Subscription.findByPk(checkoutSession.subscription_id);
91
- if (!subscription) {
92
- throw new Error('Subscription not found');
96
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
97
+
98
+ let subscriptions: Subscription[] = [];
99
+ let primarySubscription: Subscription | null = null;
100
+ if (subscriptionIds.length > 0) {
101
+ // @ts-ignore
102
+ subscriptions = await Promise.all(
103
+ subscriptionIds.filter(Boolean)
104
+ .map(id => Subscription.findByPk(id))
105
+ );
106
+
107
+ subscriptions = subscriptions.filter(Boolean);
108
+ for (const subscription of subscriptions) {
109
+ if (subscription && subscription.status !== 'incomplete') {
110
+ throw new Error(`Subscription ${subscription.id} is not in incomplete status: ${subscription.status}`);
111
+ }
93
112
  }
94
- if (subscription.status !== 'incomplete') {
95
- throw new Error('Subscription is not in incomplete status');
113
+ if (subscriptions.length > 0) {
114
+ // @ts-ignore
115
+ primarySubscription = subscriptions.find((sub) => sub.metadata?.is_primary_subscription) || subscriptions[0];
116
+ paymentCurrencyId = primarySubscription?.currency_id;
117
+ paymentMethodId = primarySubscription?.default_payment_method_id;
96
118
  }
97
-
98
- paymentCurrencyId = subscription.currency_id;
99
- paymentMethodId = subscription.default_payment_method_id;
100
119
  }
101
120
  let customer = null;
121
+
102
122
  if (!skipCustomer) {
103
123
  // 检查是否为打赏场景
104
124
  const isDonation = isDonationCheckoutSession(checkoutSession);
@@ -195,7 +215,8 @@ export async function ensurePaymentIntent(checkoutSessionId: string, userDid?: s
195
215
  checkoutSession,
196
216
  paymentIntent,
197
217
  customer: customer as Customer,
198
- subscription,
218
+ subscription: primarySubscription as Subscription,
219
+ subscriptions,
199
220
  paymentMethod,
200
221
  paymentCurrency,
201
222
  };
@@ -345,6 +366,8 @@ type Args = {
345
366
  paymentIntent?: PaymentIntent;
346
367
  subscription?: Subscription;
347
368
  props?: Partial<TInvoice>;
369
+ subscriptions?: Subscription[];
370
+ lineItems?: TLineItemExpanded[];
348
371
  };
349
372
 
350
373
  export async function ensureInvoiceForCheckout({
@@ -353,27 +376,32 @@ export async function ensureInvoiceForCheckout({
353
376
  paymentIntent,
354
377
  subscription,
355
378
  props,
379
+ subscriptions,
380
+ lineItems,
356
381
  }: Args): Promise<{ invoice: Invoice | null; items: InvoiceItem[] }> {
382
+
383
+ const isGroupInvoice = subscriptions && subscriptions.length > 1;
357
384
  // invoices are optional when checkout session is in payment mode
358
385
  if (checkoutSession.mode === 'payment' && !checkoutSession.invoice_creation?.enabled) {
359
386
  logger.warn('Invoice creation disabled for payment mode');
360
387
  return { invoice: null, items: [] };
361
388
  }
362
389
 
390
+ const existingInvoice = checkoutSession.invoice_id || (isGroupInvoice && subscription?.latest_invoice_id);
363
391
  // Do not create invoice if it's already created
364
- if (checkoutSession.invoice_id) {
365
- logger.info(`Invoice already created for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
366
- const invoice = await Invoice.findByPk(checkoutSession.invoice_id);
392
+ if (existingInvoice) {
393
+ logger.info(`Invoice already created for checkout session ${checkoutSession.id}: ${existingInvoice}`);
394
+ const invoice = await Invoice.findByPk(existingInvoice);
367
395
  if (invoice) {
368
396
  if (invoice.status === 'paid') {
369
- logger.info(`Invoice already paid for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
397
+ logger.info(`Invoice already paid for checkout session ${checkoutSession.id}: ${existingInvoice}`);
370
398
  throw new Error('Invoice already paid');
371
399
  }
372
400
 
373
401
  // invoice currency is aligned
374
402
  if (invoice.currency_id === checkoutSession.currency_id) {
375
403
  await invoice.update({ status: 'open' });
376
- logger.info(`Invoice status reset for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
404
+ logger.info(`Invoice status reset for checkout session ${checkoutSession.id}: ${existingInvoice}`);
377
405
 
378
406
  if (invoice.payment_intent_id) {
379
407
  await PaymentIntent.update({ status: 'requires_capture' }, { where: { id: invoice.payment_intent_id } });
@@ -384,13 +412,13 @@ export async function ensureInvoiceForCheckout({
384
412
 
385
413
  return {
386
414
  invoice,
387
- items: await InvoiceItem.findAll({ where: { invoice_id: checkoutSession.invoice_id } }),
415
+ items: await InvoiceItem.findAll({ where: { invoice_id: existingInvoice } }),
388
416
  };
389
417
  }
390
418
 
391
419
  // invalid currency not aligned: we should generate new invoice
392
420
  await invoice.update({ status: 'void' });
393
- logger.info(`Invoice marked void for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
421
+ logger.info(`Invoice marked void for checkout session ${checkoutSession.id}: ${existingInvoice}`);
394
422
  const method = await PaymentMethod.findByPk(invoice.default_payment_method_id);
395
423
  if (method?.type === 'stripe' && invoice.metadata?.stripe_id) {
396
424
  const client = method.getStripeClient();
@@ -398,12 +426,12 @@ export async function ensureInvoiceForCheckout({
398
426
  .voidInvoice(invoice.metadata.stripe_id)
399
427
  .then(() => {
400
428
  logger.info(
401
- `Invoice marked void on stripe for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`
429
+ `Invoice marked void on stripe for checkout session ${checkoutSession.id}: ${existingInvoice}`
402
430
  );
403
431
  })
404
432
  .catch((err) => {
405
433
  logger.error(
406
- `Invoice marked void on stripe failed for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`,
434
+ `Invoice marked void on stripe failed for checkout session ${checkoutSession.id}: ${existingInvoice}`,
407
435
  err
408
436
  );
409
437
  });
@@ -412,11 +440,26 @@ export async function ensureInvoiceForCheckout({
412
440
  }
413
441
 
414
442
  const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
443
+
444
+ const metadata = {
445
+ ...(checkoutSession.invoice_creation?.invoice_data?.metadata || {}),
446
+ };
447
+
448
+ if (isGroupInvoice) {
449
+ metadata.subscription_ids = subscriptions.map(sub => sub.id);
450
+ metadata.is_group_invoice = true;
451
+ }
452
+
453
+ const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
454
+ const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
455
+ const now = dayjs().unix();
456
+ const invoiceItems = lineItems || (await Price.expand(checkoutSession.line_items, { product: true }));
457
+ const totalAmount = getCheckoutAmount(invoiceItems, checkoutSession.currency_id, trialInDays > 0 || trialEnd > now);
415
458
  const { invoice, items } = await ensureInvoiceAndItems({
416
459
  customer,
417
460
  currency: currency as PaymentCurrency,
418
461
  subscription,
419
- lineItems: await Price.expand(checkoutSession.line_items, { product: true }),
462
+ lineItems: invoiceItems,
420
463
  trialing: !!checkoutSession.subscription_data?.trial_period_days,
421
464
  metered: false,
422
465
  props: {
@@ -436,21 +479,23 @@ export async function ensureInvoiceForCheckout({
436
479
  payment_intent_id: paymentIntent?.id,
437
480
  checkout_session_id: checkoutSession.id,
438
481
 
439
- total: checkoutSession.amount_subtotal,
482
+ total: totalAmount.subtotal,
440
483
 
441
484
  default_payment_method_id: (subscription?.default_payment_method_id ||
442
485
  paymentIntent?.payment_method_id) as string,
443
486
 
444
487
  custom_fields: checkoutSession.invoice_creation?.invoice_data?.custom_fields || [],
445
488
  footer: checkoutSession.invoice_creation?.invoice_data?.footer || '',
446
- metadata: checkoutSession.invoice_creation?.invoice_data?.metadata || {},
489
+ metadata,
447
490
  ...(props || {}),
448
491
  } as Invoice,
449
492
  });
450
493
  logger.info('Invoice created for checkoutSession', { checkoutSessionId: checkoutSession.id, invoiceId: invoice.id });
451
494
 
452
- // persist invoice id
453
- await checkoutSession.update({ invoice_id: invoice.id });
495
+ // only update invoice_id for single invoice
496
+ if (!isGroupInvoice) {
497
+ await checkoutSession.update({ invoice_id: invoice.id });
498
+ }
454
499
  if (paymentIntent) {
455
500
  await paymentIntent.update({ invoice_id: invoice.id });
456
501
  }
@@ -461,6 +506,46 @@ export async function ensureInvoiceForCheckout({
461
506
  return { invoice, items };
462
507
  }
463
508
 
509
+
510
+ export async function ensureInvoicesForSubscriptions({
511
+ checkoutSession,
512
+ customer,
513
+ subscriptions,
514
+ }: Omit<Args, 'subscription'> & { subscriptions: Subscription[] }): Promise<{
515
+ invoices: Invoice[];
516
+ }> {
517
+ if (!subscriptions?.length) {
518
+ logger.warn('No subscriptions provided for invoice creation');
519
+ return { invoices: [] };
520
+ }
521
+
522
+ const lineItems = await Price.expand(checkoutSession.line_items, { product: true });
523
+
524
+ const primarySubscription = (subscriptions.find((x) => x.metadata.is_primary_subscription) || subscriptions[0]) as Subscription;
525
+ const invoices = await Promise.all(
526
+ subscriptions.map(async (subscription) => {
527
+ const subItems = await getSubscriptionLineItems(subscription, lineItems, primarySubscription);
528
+ const { invoice } = await ensureInvoiceForCheckout({
529
+ checkoutSession,
530
+ customer,
531
+ subscription,
532
+ subscriptions,
533
+ lineItems: subItems
534
+ });
535
+ return invoice;
536
+ })
537
+ );
538
+
539
+ const createdInvoices = invoices.filter(Boolean);
540
+
541
+ logger.info(`Created ${createdInvoices.length} invoices for subscriptions`, {
542
+ checkoutSessionId: checkoutSession.id,
543
+ invoiceIds: createdInvoices.map(inv => inv?.id)
544
+ });
545
+
546
+ return { invoices: createdInvoices as Invoice[] };
547
+ }
548
+
464
549
  export async function ensureInvoiceForCollect(invoiceId: string) {
465
550
  const invoice = await Invoice.findByPk(invoiceId);
466
551
  if (!invoice) {
@@ -701,10 +786,11 @@ export async function getDelegationTxClaim({
701
786
  export async function getStakeTxClaim({
702
787
  userDid,
703
788
  userPk,
704
- items,
705
789
  subscription,
790
+ items,
706
791
  paymentCurrency,
707
792
  paymentMethod,
793
+ subscriptions,
708
794
  }: {
709
795
  userDid: string;
710
796
  userPk: string;
@@ -712,15 +798,33 @@ export async function getStakeTxClaim({
712
798
  items: TLineItemExpanded[];
713
799
  paymentCurrency: PaymentCurrency;
714
800
  paymentMethod: PaymentMethod;
801
+ subscriptions?: Subscription[];
715
802
  }) {
716
- // create staking amount
717
- const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
718
- const minStakeAmount = Number(subscription.billing_thresholds?.stake_gte || 0);
803
+ let billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
804
+ let minStakeAmount = Number(subscription.billing_thresholds?.stake_gte || 0);
805
+
806
+ const hasGrouping = subscriptions && subscriptions.length > 1;
807
+ if (hasGrouping) {
808
+ const primarySubscription = subscriptions[0] as Subscription;
809
+ // use the settings of the primary subscription, not the scattered staking
810
+ billingThreshold = Number(primarySubscription.billing_thresholds?.amount_gte || 0);
811
+ minStakeAmount = Number(primarySubscription.billing_thresholds?.stake_gte || 0);
812
+
813
+ logger.info('Using primary subscription for staking', {
814
+ primarySubscriptionId: primarySubscription.id,
815
+ billingThreshold,
816
+ minStakeAmount,
817
+ allSubscriptionIds: subscriptions.map(s => s.id)
818
+ });
819
+ }
820
+
719
821
  const threshold = fromTokenToUnit(Math.max(billingThreshold, minStakeAmount), paymentCurrency.decimal);
720
822
  const staking = getSubscriptionStakeSetup(items, paymentCurrency.id, threshold.toString());
721
823
  const amount = staking.licensed.add(staking.metered).toString();
824
+
722
825
  logger.info('getStakeTxClaim', {
723
826
  subscriptionId: subscription.id,
827
+ allSubscriptions: subscriptions?.map(s => s.id) || [],
724
828
  billingThreshold,
725
829
  minStakeAmount,
726
830
  threshold: threshold.toString(),
@@ -731,7 +835,12 @@ export async function getStakeTxClaim({
731
835
  if (paymentMethod.type === 'arcblock') {
732
836
  // create staking data
733
837
  const client = paymentMethod.getOcapClient();
734
- const address = await getCustomerStakeAddress(userDid, subscription.id);
838
+
839
+ const stakeId = hasGrouping
840
+ ? `stake-group-${subscription.id}`
841
+ : subscription.id;
842
+
843
+ const address = await getCustomerStakeAddress(userDid, stakeId);
735
844
  const { state } = await client.getStakeState({ address });
736
845
  const data = {
737
846
  type: 'json',
@@ -739,6 +848,10 @@ export async function getStakeTxClaim({
739
848
  {
740
849
  appId: wallet.address,
741
850
  subscriptionId: subscription.id,
851
+ ...(hasGrouping ? {
852
+ subscriptionGroup: true,
853
+ subscriptionIds: subscriptions.map(s => s.id)
854
+ } : {})
742
855
  },
743
856
  JSON.parse(state?.data?.value || '{}')
744
857
  ),
@@ -748,7 +861,9 @@ export async function getStakeTxClaim({
748
861
 
749
862
  return {
750
863
  type: 'StakeTx',
751
- description: `Stake to complete subscription ${subscription.id}`,
864
+ description: hasGrouping
865
+ ? `Stake for ${subscriptions.length} subscriptions`
866
+ : `Stake for subscription ${subscription.id}`,
752
867
  partialTx: {
753
868
  from: userDid,
754
869
  pk: userPk,
@@ -757,8 +872,10 @@ export async function getStakeTxClaim({
757
872
  receiver: wallet.address,
758
873
  slashers: [wallet.address],
759
874
  revokeWaitingPeriod: setup.cycle.duration / 1000, // wait for at least 1 billing cycle
760
- message: `Stake for subscription ${subscription.id}`,
761
- nonce: subscription.id,
875
+ message: hasGrouping
876
+ ? `Stake for ${subscriptions.length} subscriptions`
877
+ : `Stake for subscription ${subscription.id}`,
878
+ nonce: stakeId,
762
879
  inputs: [],
763
880
  data,
764
881
  },
@@ -767,10 +884,15 @@ export async function getStakeTxClaim({
767
884
  requirement: {
768
885
  tokens: [{ address: paymentCurrency.contract as string, value: amount }],
769
886
  },
770
- nonce: `stake-${subscription.id}`,
887
+ nonce: `stake-${stakeId}`,
771
888
  meta: {
772
889
  purpose: 'staking',
773
890
  address,
891
+ ...(hasGrouping ? {
892
+ subscriptionGroup: true,
893
+ primarySubscriptionId: subscription.id,
894
+ allSubscriptionIds: subscriptions.map(s => s.id)
895
+ } : {})
774
896
  },
775
897
  chainInfo: {
776
898
  host: paymentMethod.settings?.arcblock?.api_host as string,