payment-kit 1.13.30 → 1.13.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 (45) hide show
  1. package/api/src/integrations/blockchain/nft.ts +0 -1
  2. package/api/src/integrations/blocklet/passport.ts +1 -1
  3. package/api/src/integrations/stripe/handlers/invoice.ts +44 -2
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +32 -8
  5. package/api/src/integrations/stripe/resource.ts +7 -4
  6. package/api/src/jobs/subscription.ts +1 -1
  7. package/api/src/libs/payment.ts +6 -1
  8. package/api/src/libs/session.ts +78 -27
  9. package/api/src/libs/util.ts +15 -0
  10. package/api/src/routes/checkout-sessions.ts +161 -20
  11. package/api/src/routes/connect/collect.ts +5 -9
  12. package/api/src/routes/connect/pay.ts +5 -9
  13. package/api/src/routes/connect/setup.ts +22 -10
  14. package/api/src/routes/connect/shared.ts +13 -10
  15. package/api/src/routes/connect/subscribe.ts +29 -20
  16. package/api/src/routes/invoices.ts +5 -1
  17. package/api/src/routes/payment-intents.ts +5 -1
  18. package/api/src/routes/payment-links.ts +3 -2
  19. package/api/src/routes/prices.ts +32 -21
  20. package/api/src/store/migrations/20231023-upsell.ts +11 -0
  21. package/api/src/store/models/index.ts +10 -2
  22. package/api/src/store/models/price.ts +89 -23
  23. package/api/src/store/models/types.ts +1 -0
  24. package/blocklet.yml +1 -1
  25. package/package.json +17 -17
  26. package/src/components/blockchain/tx.tsx +3 -1
  27. package/src/components/checkout/pay.tsx +39 -19
  28. package/src/components/checkout/product-card.tsx +2 -6
  29. package/src/components/checkout/product-item.tsx +84 -21
  30. package/src/components/checkout/summary.tsx +11 -2
  31. package/src/components/info-row.tsx +3 -1
  32. package/src/components/invoice/table.tsx +1 -1
  33. package/src/components/price/upsell-select.tsx +83 -0
  34. package/src/components/price/upsell.tsx +74 -0
  35. package/src/components/status.tsx +1 -1
  36. package/src/components/subscription/actions/cancel.tsx +25 -27
  37. package/src/components/subscription/items/index.tsx +1 -1
  38. package/src/libs/util.ts +51 -31
  39. package/src/locales/en.tsx +23 -2
  40. package/src/locales/zh.tsx +52 -40
  41. package/src/pages/admin/billing/index.tsx +3 -3
  42. package/src/pages/admin/customers/customers/detail.tsx +1 -0
  43. package/src/pages/admin/index.tsx +1 -0
  44. package/src/pages/admin/products/prices/detail.tsx +7 -0
  45. package/src/pages/customer/invoice.tsx +7 -6
@@ -118,7 +118,6 @@ export async function mintNftForCheckoutSession(id: string) {
118
118
  factoryState.name
119
119
  );
120
120
  logger.info('nft sent for checkoutSession', { id, nftOwner });
121
- await checkoutSession.update({ nft_mint_status: 'sent' });
122
121
 
123
122
  return;
124
123
  }
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  export async function checkPassportForPaymentLink(doc: PaymentLink) {
17
17
  // @ts-ignore
18
- const items: TLineItemExpanded[] = await Price.expand(doc.line_items, true);
18
+ const items: TLineItemExpanded[] = await Price.expand(doc.line_items);
19
19
  const item = items.find((x) => x.price.product?.metadata?.passport);
20
20
  return item?.price.product?.metadata?.passport;
21
21
  }
@@ -20,16 +20,57 @@ export async function handleStripeInvoicePaid(invoice: Invoice, event: TEventExp
20
20
  await invoice.update({
21
21
  status: 'paid',
22
22
  ...pick(event.data.object, [
23
- 'paid',
24
- 'paid_out_of_band',
25
23
  'amount_due',
26
24
  'amount_paid',
27
25
  'amount_remaining',
26
+ 'last_finalization_error',
27
+ 'paid_out_of_band',
28
+ 'paid',
28
29
  'status_transitions',
30
+ 'subtotal_excluding_tax',
31
+ 'subtotal',
32
+ 'tax',
33
+ 'total_discount_amounts',
34
+ 'total',
29
35
  ]),
30
36
  });
31
37
  }
32
38
 
39
+ export async function syncStripeInvoice(invoice: Invoice) {
40
+ if (!invoice.metadata?.stripe_id) {
41
+ return;
42
+ }
43
+
44
+ const method = await PaymentMethod.findByPk(invoice.default_payment_method_id);
45
+ if (!method) {
46
+ return;
47
+ }
48
+
49
+ const client = await method.getStripeClient();
50
+ const stripeInvoice = await client.invoices.retrieve(invoice.metadata.stripe_id);
51
+ if (stripeInvoice) {
52
+ await invoice.update(
53
+ // @ts-ignore
54
+ pick(stripeInvoice, [
55
+ 'amount_due',
56
+ 'amount_paid',
57
+ 'amount_remaining',
58
+ 'last_finalization_error',
59
+ 'paid_out_of_band',
60
+ 'paid',
61
+ 'status_transitions',
62
+ 'status',
63
+ 'subtotal_excluding_tax',
64
+ 'subtotal',
65
+ 'tax',
66
+ 'total_discount_amounts',
67
+ 'total',
68
+ ])
69
+ );
70
+ logger.info('stripe invoice synced', { locale: invoice.id, remote: stripeInvoice.id });
71
+ }
72
+ }
73
+
33
74
  export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subscription, client: Stripe) {
34
75
  const customer = await Customer.findByPk(subscription.customer_id);
35
76
  const checkoutSession = await CheckoutSession.findOne({ where: { subscription_id: subscription.id } });
@@ -76,6 +117,7 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
76
117
  'tax',
77
118
  'total_discount_amounts',
78
119
  'total',
120
+ 'last_finalization_error',
79
121
  ]),
80
122
 
81
123
  currency_id: subscription.currency_id,
@@ -6,7 +6,7 @@ import type Stripe from 'stripe';
6
6
 
7
7
  import dayjs from '../../../libs/dayjs';
8
8
  import logger from '../../../libs/logger';
9
- import { CheckoutSession, Invoice, PaymentIntent, TEventExpanded } from '../../../store/models';
9
+ import { CheckoutSession, Invoice, PaymentIntent, PaymentMethod, TEventExpanded } from '../../../store/models';
10
10
  import { handleStripeInvoiceCreated } from './invoice';
11
11
 
12
12
  export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, event?: TEventExpanded) {
@@ -46,6 +46,31 @@ export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, e
46
46
  }
47
47
  }
48
48
 
49
+ export async function syncStripPayment(paymentIntent: PaymentIntent) {
50
+ if (!paymentIntent.metadata?.stripe_id) {
51
+ return;
52
+ }
53
+
54
+ const method = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
55
+ if (!method) {
56
+ return;
57
+ }
58
+
59
+ const client = await method.getStripeClient();
60
+ const stripeIntent = await client.paymentIntents.retrieve(paymentIntent.metadata.stripe_id);
61
+ if (stripeIntent) {
62
+ // @ts-ignore
63
+ await paymentIntent.update({
64
+ amount: String(stripeIntent.amount),
65
+ amount_received: String(stripeIntent.amount_received),
66
+ amount_capturable: String(stripeIntent.amount_capturable),
67
+ amount_details: stripeIntent.amount_details as any,
68
+ ...pick(stripeIntent, ['status', 'confirmation_method', 'capture_method', 'last_payment_error']),
69
+ });
70
+ logger.info('stripe payment intent synced', { locale: paymentIntent.id, remote: stripeIntent.id });
71
+ }
72
+ }
73
+
49
74
  export async function handleStripePaymentCreated(event: TEventExpanded, client: Stripe) {
50
75
  logger.info('possible payment intent from subscription', { id: event.id, type: event.type });
51
76
 
@@ -69,9 +94,9 @@ export async function handleStripePaymentCreated(event: TEventExpanded, client:
69
94
  payment_method_types: ['stripe'],
70
95
 
71
96
  amount: String(stripeIntent.amount),
72
- amount_received: '0',
97
+ amount_received: String(stripeIntent.amount_received),
73
98
  amount_capturable: String(stripeIntent.amount_capturable),
74
- amount_details: { tip: '0' },
99
+ amount_details: stripeIntent.amount_details,
75
100
 
76
101
  ...pick(stripeIntent, [
77
102
  'livemode',
@@ -83,6 +108,7 @@ export async function handleStripePaymentCreated(event: TEventExpanded, client:
83
108
  'statement_descriptor',
84
109
  'statement_descriptor_suffix',
85
110
  'setup_future_usage',
111
+ 'last_payment_error',
86
112
  ]),
87
113
 
88
114
  metadata: {
@@ -113,11 +139,9 @@ export async function handlePaymentIntentEvent(event: TEventExpanded, client: St
113
139
  const localIntentId = event.data.object.metadata?.id;
114
140
  if (!localIntentId) {
115
141
  // We only handle payment_intents created from subscriptions
116
- if (event.type === 'payment_intent.created') {
117
- if (event.data.object.invoice) {
118
- await handleStripePaymentCreated(event, client);
119
- return;
120
- }
142
+ if (event.data.object.invoice) {
143
+ await handleStripePaymentCreated(event, client);
144
+ return;
121
145
  }
122
146
 
123
147
  try {
@@ -205,7 +205,8 @@ export async function ensureStripeSubscription(
205
205
  const customer = await ensureStripePaymentCustomer(internal, method);
206
206
  const prices = await Promise.all(
207
207
  items.map(async (x: any) => {
208
- x.stripePrice = await ensureStripePrice(x.price as any, method, currency);
208
+ const price = x.upsell_price || x.price;
209
+ x.stripePrice = await ensureStripePrice(price as any, method, currency);
209
210
  return x;
210
211
  })
211
212
  );
@@ -213,7 +214,8 @@ export async function ensureStripeSubscription(
213
214
  const recurringItems = prices
214
215
  .filter((x) => x.price.type === 'recurring')
215
216
  .map((x) => {
216
- if (x.price.recurring?.usage_type === 'metered') {
217
+ const price = x.upsell_price || x.price;
218
+ if (price.recurring?.usage_type === 'metered') {
217
219
  return { price: x.stripePrice.id };
218
220
  }
219
221
  return { price: x.stripePrice.id, quantity: x.quantity };
@@ -242,8 +244,9 @@ export async function ensureStripeSubscription(
242
244
  await Promise.all(
243
245
  stripeSubscription.items.data.map(async (x: any) => {
244
246
  const item = prices.find((y) => y.stripePrice.id === x.price.id);
247
+ const price = item.upsell_price || item.price; // local
245
248
  let exist = await SubscriptionItem.findOne({
246
- where: { price_id: item.price_id, subscription_id: internal.id },
249
+ where: { price_id: price.id, subscription_id: internal.id },
247
250
  });
248
251
  if (exist) {
249
252
  await exist.update({ metadata: { stripe_id: x.id, stripe_subscription_id: stripeSubscription.id } });
@@ -252,7 +255,7 @@ export async function ensureStripeSubscription(
252
255
  } else {
253
256
  exist = await SubscriptionItem.create({
254
257
  livemode: stripeSubscription.livemode,
255
- price_id: item.price.id,
258
+ price_id: price.id,
256
259
  quantity: x.quantity,
257
260
  subscription_id: internal.id,
258
261
  billing_thresholds: x.billing_threshold,
@@ -134,7 +134,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
134
134
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
135
135
  let expandedItems = await Price.expand(
136
136
  subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
137
- true
137
+ { product: true }
138
138
  );
139
139
 
140
140
  // get usage summaries for this billing cycle
@@ -31,13 +31,18 @@ export async function isDelegationSufficientForPayment(args: {
31
31
  return { sufficient: false, reason: 'NO_DELEGATION' };
32
32
  }
33
33
 
34
+ // have enough permissions
35
+ if (state.ops.some((x: any) => x.key === 'fg:t:transfer_v2') === false) {
36
+ return { sufficient: false, reason: 'NO_TRANSFER_PERMISSION' };
37
+ }
38
+
34
39
  // balance enough token for payment?
35
40
  const { tokens } = await client.getAccountTokens({ address: delegator, token: paymentCurrency.contract as string });
36
41
  const [token] = tokens;
37
42
  if (!token) {
38
43
  return { sufficient: false, reason: 'NO_TOKEN' };
39
44
  }
40
- if (new BN(amount).gt(new BN(token.balance))) {
45
+ if (new BN(token.balance).lte(new BN(amount))) {
41
46
  return { sufficient: false, reason: 'NO_ENOUGH_TOKEN' };
42
47
  }
43
48
 
@@ -3,14 +3,12 @@ import { BN } from '@ocap/util';
3
3
  import cloneDeep from 'lodash/cloneDeep';
4
4
  import isEqual from 'lodash/isEqual';
5
5
 
6
- import type { TPaymentCurrency, TPaymentMethodExpanded } from '../store/models';
6
+ import type { TLineItemExpanded, TPaymentCurrency, TPaymentMethodExpanded } from '../store/models';
7
7
  import type { Price, TPrice } from '../store/models/price';
8
8
  import type { Product } from '../store/models/product';
9
- import type { LineItem, PriceCurrency, PriceRecurring } from '../store/models/types';
9
+ import type { PriceCurrency, PriceRecurring } from '../store/models/types';
10
10
  import dayjs from './dayjs';
11
11
 
12
- export type TLineItemExpanded = LineItem & { price: TPrice };
13
-
14
12
  export function getStatementDescriptor(items: any[]) {
15
13
  for (const item of items) {
16
14
  if (item.price?.product?.statement_descriptor) {
@@ -58,35 +56,26 @@ export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
58
56
 
59
57
  // FIXME: apply coupon for discounts
60
58
  export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPaymentCurrency, includeFreeTrial = false) {
61
- const subtotal = items
62
- .reduce((acc, x) => {
63
- if (x.price.type === 'recurring') {
64
- if (includeFreeTrial) {
65
- return acc;
66
- }
67
- if (x.price.recurring?.usage_type === 'metered') {
68
- return acc;
69
- }
70
- }
71
- return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
72
- }, new BN(0))
73
- .toString();
59
+ let renew = new BN(0);
74
60
 
75
61
  const total = items
76
62
  .reduce((acc, x) => {
77
- if (x.price.type === 'recurring') {
63
+ const price = x.upsell_price || x.price;
64
+ if (price.type === 'recurring') {
65
+ renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
66
+
78
67
  if (includeFreeTrial) {
79
68
  return acc;
80
69
  }
81
- if (x.price.recurring?.usage_type === 'metered') {
70
+ if (price.recurring?.usage_type === 'metered') {
82
71
  return acc;
83
72
  }
84
73
  }
85
- return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
74
+ return acc.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
86
75
  }, new BN(0))
87
76
  .toString();
88
77
 
89
- return { subtotal, total, discount: '0', shipping: '0', tax: '0' };
78
+ return { subtotal: total, total, renew: renew.toString(), discount: '0', shipping: '0', tax: '0' };
90
79
  }
91
80
 
92
81
  export function getRecurringPeriod(recurring: PriceRecurring) {
@@ -115,15 +104,17 @@ export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currency:
115
104
  let subscription = new BN(0);
116
105
 
117
106
  items.forEach((x) => {
118
- setup = setup.add(new BN(x.price.unit_amount).mul(new BN(x.quantity)));
119
- if (x.price.type === 'recurring') {
107
+ const price = x.upsell_price || x.price;
108
+ setup = setup.add(new BN(price.unit_amount).mul(new BN(x.quantity)));
109
+ if (price.type === 'recurring') {
120
110
  if (trialInDays === 0) {
121
- subscription = setup.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
111
+ subscription = setup.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
122
112
  }
123
113
  }
124
114
  });
125
115
 
126
- const recurring = items.find((x) => x.price.type === 'recurring')?.price.recurring as PriceRecurring;
116
+ const item = items.find((x) => x.price.type === 'recurring');
117
+ const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
127
118
  const cycle = getRecurringPeriod(recurring);
128
119
  const trial = trialInDays ? trialInDays * 24 * 60 * 60 * 1000 : 0;
129
120
 
@@ -205,7 +196,7 @@ export function getSupportedPaymentCurrencies(items: TLineItemExpanded[]) {
205
196
  }
206
197
 
207
198
  export function isLineItemCurrencyAligned(list: TLineItemExpanded[], index: number) {
208
- const prices = list.map((x) => x.price);
199
+ const prices = list.map((x) => x.upsell_price || x.price);
209
200
 
210
201
  const current = getPriceCurrencyOptions(prices[index] as TPrice)
211
202
  .map((x) => x.currency_id)
@@ -224,7 +215,7 @@ export function isLineItemCurrencyAligned(list: TLineItemExpanded[], index: numb
224
215
  }
225
216
 
226
217
  export function isLineItemRecurringAligned(list: TLineItemExpanded[], index: number) {
227
- const prices = list.map((x) => x.price);
218
+ const prices = list.map((x) => x.upsell_price || x.price);
228
219
 
229
220
  if (prices[index]?.type !== 'recurring') {
230
221
  return true;
@@ -258,3 +249,63 @@ export function isLineItemAligned(list: TLineItemExpanded[], index: number) {
258
249
  aligned: currency && recurring,
259
250
  };
260
251
  }
252
+
253
+ // FIXME: upsell validate https://stripe.com/docs/payments/checkout/upsells
254
+ export function canUpsell(from: TPrice, to: TPrice) {
255
+ if (from.id === to.id) {
256
+ return false;
257
+ }
258
+ if (from.product_id !== to.product_id) {
259
+ return false;
260
+ }
261
+ if (to.active === false) {
262
+ return false;
263
+ }
264
+ if (to.type !== 'recurring') {
265
+ return false;
266
+ }
267
+
268
+ // longer periods
269
+ const fromPeriod = getRecurringPeriod(from.recurring as PriceRecurring);
270
+ const toPeriod = getRecurringPeriod(to.recurring as PriceRecurring);
271
+ if (fromPeriod >= toPeriod) {
272
+ return false;
273
+ }
274
+
275
+ // and lower prices
276
+ const fromPrice = new BN(from.unit_amount).div(new BN(fromPeriod));
277
+ const toPrice = new BN(to.unit_amount).div(new BN(toPeriod));
278
+ if (fromPrice.lt(toPrice)) {
279
+ return false;
280
+ }
281
+
282
+ return true;
283
+ }
284
+
285
+ export function getFastCheckoutAmount(
286
+ items: TLineItemExpanded[],
287
+ mode: string,
288
+ currency: TPaymentCurrency,
289
+ includeFreeTrial = false,
290
+ minimumCycle = 2
291
+ ) {
292
+ if (minimumCycle < 1) {
293
+ // eslint-disable-next-line no-param-reassign
294
+ minimumCycle = 1;
295
+ }
296
+
297
+ const { total, renew } = getCheckoutAmount(items, currency, includeFreeTrial);
298
+ if (mode === 'payment') {
299
+ return total;
300
+ }
301
+
302
+ if (mode === 'setup') {
303
+ return new BN(renew).mul(new BN(minimumCycle)).toString();
304
+ }
305
+
306
+ if (mode === 'subscription') {
307
+ return new BN(total).add(new BN(renew).mul(new BN(minimumCycle - 1))).toString();
308
+ }
309
+
310
+ return '0';
311
+ }
@@ -1,6 +1,7 @@
1
1
  import crypto from 'crypto';
2
2
 
3
3
  import { getUrl } from '@blocklet/sdk/lib/component';
4
+ import env from '@blocklet/sdk/lib/env';
4
5
  import { customAlphabet } from 'nanoid';
5
6
 
6
7
  import dayjs from './dayjs';
@@ -142,3 +143,17 @@ export const getNextRetry = (retryCount: number) => {
142
143
  export const getWebhookJobId = (eventId: string, webhookId: string) => {
143
144
  return md5([eventId, webhookId].join('-'));
144
145
  };
146
+
147
+ export function getTxMetadata(extra: Record<string, any> = {}): any {
148
+ return {
149
+ type: 'json',
150
+ value: {
151
+ paymentKit: {
152
+ // FIXME: this should be customizable
153
+ description: `Manage subscriptions and payments on ${env.appName}`,
154
+ customerPortal: getUrl('/customer'),
155
+ ...extra,
156
+ },
157
+ },
158
+ };
159
+ }