payment-kit 1.13.72 → 1.13.74

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 (66) hide show
  1. package/api/src/{schedule → crons}/base.ts +1 -1
  2. package/api/src/index.ts +7 -7
  3. package/api/src/integrations/stripe/handlers/customer.ts +24 -0
  4. package/api/src/integrations/stripe/handlers/index.ts +4 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
  6. package/api/src/integrations/stripe/resource.ts +1 -1
  7. package/api/src/libs/audit.ts +34 -28
  8. package/api/src/libs/payment.ts +48 -4
  9. package/api/src/libs/queue/index.ts +18 -1
  10. package/api/src/libs/queue/store.ts +6 -5
  11. package/api/src/libs/session.ts +13 -12
  12. package/api/src/libs/subscription.ts +26 -0
  13. package/api/src/libs/util.ts +5 -1
  14. package/api/src/{jobs → queues}/checkout-session.ts +23 -1
  15. package/api/src/{jobs → queues}/invoice.ts +15 -6
  16. package/api/src/{jobs → queues}/payment.ts +182 -30
  17. package/api/src/{jobs → queues}/subscription.ts +36 -104
  18. package/api/src/{jobs → queues}/webhook.ts +2 -0
  19. package/api/src/routes/checkout-sessions.ts +72 -26
  20. package/api/src/routes/connect/collect.ts +2 -2
  21. package/api/src/routes/connect/pay.ts +1 -1
  22. package/api/src/routes/connect/setup.ts +10 -3
  23. package/api/src/routes/connect/shared.ts +98 -49
  24. package/api/src/routes/connect/subscribe.ts +10 -4
  25. package/api/src/routes/pricing-table.ts +2 -0
  26. package/api/src/routes/subscription-items.ts +1 -1
  27. package/api/src/routes/subscriptions.ts +434 -13
  28. package/api/src/store/migrate.ts +0 -1
  29. package/api/src/store/migrations/20231204-subupdate.ts +50 -0
  30. package/api/src/store/migrations/20231220-setup-intent.ts +22 -0
  31. package/api/src/store/models/checkout-session.ts +8 -0
  32. package/api/src/store/models/customer.ts +52 -15
  33. package/api/src/store/models/invoice-item.ts +6 -1
  34. package/api/src/store/models/invoice.ts +41 -22
  35. package/api/src/store/models/payment-intent.ts +4 -0
  36. package/api/src/store/models/setup-intent.ts +4 -0
  37. package/api/src/store/models/subscription-item.ts +0 -4
  38. package/api/src/store/models/subscription.ts +77 -44
  39. package/api/src/store/models/types.ts +1 -0
  40. package/api/src/store/sequelize.ts +6 -0
  41. package/api/third.d.ts +2 -0
  42. package/blocklet.yml +1 -1
  43. package/jest.config.js +14 -0
  44. package/package.json +24 -19
  45. package/src/components/blockchain/tx.tsx +20 -11
  46. package/src/components/checkout/form/index.tsx +1 -1
  47. package/src/components/invoice/table.tsx +58 -19
  48. package/src/components/layout/admin.tsx +17 -5
  49. package/src/components/portal/invoice/list.tsx +12 -8
  50. package/src/components/portal/subscription/list.tsx +114 -77
  51. package/src/components/subscription/status.tsx +21 -19
  52. package/src/global.css +4 -0
  53. package/src/locales/en.tsx +14 -1
  54. package/src/locales/zh.tsx +14 -0
  55. package/src/pages/admin/customers/customers/detail.tsx +47 -3
  56. package/src/pages/admin/overview.tsx +21 -1
  57. package/src/pages/admin/payments/intents/detail.tsx +12 -3
  58. package/src/pages/customer/invoice.tsx +15 -1
  59. package/src/pages/customer/subscription/index.tsx +9 -2
  60. package/tests/api/libs/subscription.spec.ts +45 -0
  61. /package/api/src/{schedule → crons}/index.ts +0 -0
  62. /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
  63. /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
  64. /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
  65. /package/api/src/{jobs → queues}/event.ts +0 -0
  66. /package/api/src/{jobs → queues}/notification.ts +0 -0
@@ -2,10 +2,10 @@ import dayjs from 'dayjs';
2
2
  import { clone } from 'lodash';
3
3
  import pAll from 'p-all';
4
4
 
5
- import { NotificationQueueJob, notificationQueue } from '../jobs/notification';
6
5
  import { notificationCronConcurrency } from '../libs/env';
7
6
  import logger from '../libs/logger';
8
7
  import type { SubscriptionTrialWillEndEmailTemplateOptions } from '../libs/notification/template/subscription-trial-will-end';
8
+ import { NotificationQueueJob, notificationQueue } from '../queues/notification';
9
9
  import type { Subscription } from '../store/models';
10
10
  import type { Diff } from './interface/diff';
11
11
 
package/api/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import 'express-async-errors';
2
2
 
3
- import './schedule';
3
+ import './crons';
4
4
 
5
5
  import path from 'path';
6
6
 
@@ -13,15 +13,15 @@ import morgan from 'morgan';
13
13
 
14
14
  import { ensureStakedForGas } from './integrations/blockchain/stake';
15
15
  import { ensureWebhookRegistered } from './integrations/stripe/setup';
16
- import { startCheckoutSessionQueue } from './jobs/checkout-session';
17
- import { startEventQueue } from './jobs/event';
18
- import { startInvoiceQueue } from './jobs/invoice';
19
- import { startNotificationQueue } from './jobs/notification';
20
- import { startPaymentQueue } from './jobs/payment';
21
- import { startSubscriptionQueue } from './jobs/subscription';
22
16
  import { handlers } from './libs/auth';
23
17
  import logger, { accessLogStream } from './libs/logger';
24
18
  import { ensureI18n } from './libs/middleware';
19
+ import { startCheckoutSessionQueue } from './queues/checkout-session';
20
+ import { startEventQueue } from './queues/event';
21
+ import { startInvoiceQueue } from './queues/invoice';
22
+ import { startNotificationQueue } from './queues/notification';
23
+ import { startPaymentQueue } from './queues/payment';
24
+ import { startSubscriptionQueue } from './queues/subscription';
25
25
  import routes from './routes';
26
26
  import collectHandlers from './routes/connect/collect';
27
27
  import payHandlers from './routes/connect/pay';
@@ -0,0 +1,24 @@
1
+ import logger from '../../../libs/logger';
2
+ import { Customer, TEventExpanded } from '../../../store/models';
3
+
4
+ export async function handleCustomerEvent(event: TEventExpanded) {
5
+ const localId = event.data.object.metadata?.id;
6
+ if (!localId) {
7
+ logger.warn('local customer id not found in strip event', { id: event.id, type: event.type });
8
+ return;
9
+ }
10
+
11
+ const customer = await Customer.findByPk(localId);
12
+ if (!customer) {
13
+ logger.warn('local customer not found', { localId });
14
+ return;
15
+ }
16
+
17
+ logger.info('received customer event', { id: event.id, type: event.type, localId });
18
+
19
+ if (event.type === 'customer.updated') {
20
+ if (event.data.object.balance !== customer.balance) {
21
+ await customer.update({ balance: event.data.object.balance });
22
+ }
23
+ }
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type Stripe from 'stripe';
2
2
 
3
+ import { handleCustomerEvent } from './customer';
3
4
  import { handleInvoiceEvent } from './invoice';
4
5
  import { handlePaymentIntentEvent } from './payment-intent';
5
6
  import { handleSetupIntentEvent } from './setup-intent';
@@ -32,6 +33,9 @@ export default function handleStripeEvent(event: any, client: Stripe) {
32
33
  case 'customer.subscription.updated':
33
34
  return handleSubscriptionEvent(event, client);
34
35
 
36
+ case 'customer.updated':
37
+ return handleCustomerEvent(event);
38
+
35
39
  case 'invoice.created':
36
40
  case 'invoice.deleted':
37
41
  case 'invoice.finalization_failed':
@@ -4,9 +4,9 @@ import pick from 'lodash/pick';
4
4
  import pWaitFor from 'p-wait-for';
5
5
  import type Stripe from 'stripe';
6
6
 
7
- import { handlePaymentSucceed } from '../../../jobs/payment';
8
7
  import dayjs from '../../../libs/dayjs';
9
8
  import logger from '../../../libs/logger';
9
+ import { handlePaymentSucceed } from '../../../queues/payment';
10
10
  import { Invoice, PaymentIntent, PaymentMethod, TEventExpanded } from '../../../store/models';
11
11
  import { handleStripeInvoiceCreated } from './invoice';
12
12
 
@@ -90,7 +90,7 @@ export async function ensureStripePrice(internal: Price, method: PaymentMethod,
90
90
  if (internal.custom_unit_amount) {
91
91
  attrs.custom_unit_amount = internal.custom_unit_amount;
92
92
  } else {
93
- attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency));
93
+ attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency.id));
94
94
  }
95
95
 
96
96
  // create stripe price
@@ -16,21 +16,24 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
16
16
  data.previous_attributes = pick(model._previousDataValues, options.fields);
17
17
  }
18
18
 
19
- const event = await Event.create({
20
- type,
21
- api_version: API_VERSION,
22
- livemode: !!model.livemode,
23
- object_id: model.id,
24
- object_type: scope,
25
- data,
26
- request: {
27
- // FIXME:
28
- id: '',
29
- idempotency_key: '',
19
+ const event = await Event.create(
20
+ {
21
+ type,
22
+ api_version: API_VERSION,
23
+ livemode: !!model.livemode,
24
+ object_id: model.id,
25
+ object_type: scope,
26
+ data,
27
+ request: {
28
+ // FIXME:
29
+ id: '',
30
+ idempotency_key: '',
31
+ },
32
+ metadata: {},
33
+ pending_webhooks: 99, // force all events goto the event queue
30
34
  },
31
- metadata: {},
32
- pending_webhooks: 99, // force all events goto the event queue
33
- });
35
+ { transaction: null }
36
+ );
34
37
 
35
38
  events.emit('event.created', { id: event.id });
36
39
  events.emit(event.type, data.object);
@@ -58,21 +61,24 @@ export async function createStatusEvent(
58
61
  }
59
62
 
60
63
  const suffix = config[data.object.status];
61
- const event = await Event.create({
62
- type: [prefix, suffix].join('.'),
63
- api_version: API_VERSION,
64
- livemode: !!model.livemode,
65
- object_id: model.id,
66
- object_type: scope,
67
- data,
68
- request: {
69
- // FIXME:
70
- id: '',
71
- idempotency_key: '',
64
+ const event = await Event.create(
65
+ {
66
+ type: [prefix, suffix].join('.'),
67
+ api_version: API_VERSION,
68
+ livemode: !!model.livemode,
69
+ object_id: model.id,
70
+ object_type: scope,
71
+ data,
72
+ request: {
73
+ // FIXME:
74
+ id: '',
75
+ idempotency_key: '',
76
+ },
77
+ metadata: {},
78
+ pending_webhooks: 99, // force all events goto the event queue
72
79
  },
73
- metadata: {},
74
- pending_webhooks: 99, // force all events goto the event queue
75
- });
80
+ { transaction: null }
81
+ );
76
82
 
77
83
  events.emit('event.created', { id: event.id });
78
84
  events.emit(event.type, data.object);
@@ -8,7 +8,15 @@ import { BN, fromUnitToToken } from '@ocap/util';
8
8
  import cloneDeep from 'lodash/cloneDeep';
9
9
  import type { LiteralUnion } from 'type-fest';
10
10
 
11
- import { Invoice, PaymentCurrency, PaymentIntent, PaymentMethod } from '../store/models';
11
+ import {
12
+ CheckoutSession,
13
+ Invoice,
14
+ PaymentCurrency,
15
+ PaymentIntent,
16
+ PaymentMethod,
17
+ TCustomer,
18
+ TLineItemExpanded,
19
+ } from '../store/models';
12
20
  import type { TPaymentCurrency } from '../store/models/payment-currency';
13
21
  import { blocklet, wallet } from './auth';
14
22
  import logger from './logger';
@@ -80,7 +88,7 @@ export async function isDelegationSufficientForPayment(args: {
80
88
 
81
89
  const requested = new BN(amount);
82
90
  const allowance = new BN(tokenLimit.txAllowance);
83
- if (requested.gt(allowance)) {
91
+ if (tokenLimit.txAllowance !== '0' && requested.gt(allowance)) {
84
92
  return { sufficient: false, reason: 'NO_ENOUGH_ALLOWANCE' };
85
93
  }
86
94
  }
@@ -105,6 +113,31 @@ export async function isDelegationSufficientForPayment(args: {
105
113
  throw new Error(`Payment method ${paymentMethod.type} not supported`);
106
114
  }
107
115
 
116
+ export function isBalanceSufficientForPayment(args: {
117
+ paymentMethod: PaymentMethod;
118
+ paymentCurrency: TPaymentCurrency;
119
+ customer: TCustomer;
120
+ amount: string;
121
+ }): { sufficient: boolean; balance: string; reason?: string } {
122
+ const { paymentMethod, paymentCurrency, customer, amount } = args;
123
+ if (paymentMethod.type === 'stripe') {
124
+ return { sufficient: false, balance: customer.balance || '0', reason: 'NOT_SUPPORTED' };
125
+ }
126
+
127
+ const tokens = customer.token_balance || {};
128
+ const balance = tokens[paymentCurrency.id] || '0';
129
+
130
+ if (amount === '0') {
131
+ return { sufficient: false, balance, reason: 'NO_REQUIREMENT' };
132
+ }
133
+
134
+ if (new BN(balance).lt(new BN(amount))) {
135
+ return { sufficient: false, balance, reason: 'NO_ENOUGH_TOKEN' };
136
+ }
137
+
138
+ return { sufficient: true, balance };
139
+ }
140
+
108
141
  export function getGasPayerExtra(txBuffer: Buffer) {
109
142
  const txHash = toTxHash(txBuffer);
110
143
  return {
@@ -181,6 +214,7 @@ export async function getPaymentDetail(userDid: string, invoice: Invoice): Promi
181
214
  }
182
215
 
183
216
  export async function getTokenLimitsForDelegation(
217
+ checkoutSession: CheckoutSession,
184
218
  paymentMethod: PaymentMethod,
185
219
  paymentCurrency: PaymentCurrency,
186
220
  address: string,
@@ -189,11 +223,15 @@ export async function getTokenLimitsForDelegation(
189
223
  const client = paymentMethod.getOcapClient();
190
224
  const { state } = await client.getDelegateState({ address });
191
225
 
226
+ const items = checkoutSession.line_items as TLineItemExpanded[];
227
+ const hasMetered = items.some((x) => x.price.recurring?.usage_type === 'metered');
228
+ const allowance = hasMetered ? '0' : amount;
229
+
192
230
  // @ts-ignore
193
231
  const entry: TokenLimit = {
194
232
  address: paymentCurrency.contract as string,
195
233
  to: [wallet.address], // FIXME: may broken if we have vault, migrated
196
- txAllowance: amount,
234
+ txAllowance: allowance,
197
235
  totalAllowance: '0',
198
236
  txCount: 0,
199
237
  validUntil: 0,
@@ -204,6 +242,11 @@ export async function getTokenLimitsForDelegation(
204
242
  return [entry];
205
243
  }
206
244
 
245
+ // If we have metered items, we should not limit tx allowance(set to 0)
246
+ if (hasMetered) {
247
+ return [entry];
248
+ }
249
+
207
250
  const op = (state as DelegateState).ops.find((x) => x.key === OCAP_PAYMENT_TX_TYPE);
208
251
  if (op && Array.isArray(op.value.limit?.tokens) && op.value.limit.tokens.length > 0) {
209
252
  const tokenLimits = cloneDeep(op.value.limit.tokens);
@@ -212,7 +255,8 @@ export async function getTokenLimitsForDelegation(
212
255
  if (index > -1) {
213
256
  const limit = op.value.limit.tokens[index] as TokenLimit;
214
257
  // If we have a previous delegation and the txAllowance is smaller than requested amount
215
- if (new BN(limit.txAllowance).lt(new BN(amount))) {
258
+ // If txAllowance is 0 (unlimited), we should not update it
259
+ if (limit.txAllowance !== '0' && new BN(limit.txAllowance).lt(new BN(amount))) {
216
260
  tokenLimits[index] = entry;
217
261
  }
218
262
  } else {
@@ -125,7 +125,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
125
125
 
126
126
  const onJobComplete = async (err: any, result: any) => {
127
127
  if (result === CANCELLED) {
128
- emit('cancelled', { id: jobId, job, result });
128
+ emit('cancelled', { id: jobId, job });
129
129
  clearJob(jobId);
130
130
  return;
131
131
  }
@@ -188,6 +188,22 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
188
188
  return jobEvents;
189
189
  };
190
190
 
191
+ const pushAndWait = (params: PushParams<T>) =>
192
+ new Promise((resolve, reject) => {
193
+ try {
194
+ if (params.runAt || params.delay) {
195
+ console.warn('You may have to wait for a long time to get the result of the delayed job');
196
+ }
197
+
198
+ const job = push(params);
199
+ job.on('finished', (data: { id: string; job: T; result: any }) => resolve(data));
200
+ job.on('canceled', (data: { id: string; job: T }) => resolve(data));
201
+ job.on('failed', (data: { id: string; job: T; error: Error }) => reject(data));
202
+ } catch (err) {
203
+ reject(err);
204
+ }
205
+ });
206
+
191
207
  const cancel = async (id: string) => {
192
208
  const doc = await store.updateJob(id, { cancelled: true });
193
209
  return doc ? doc.job : null;
@@ -258,6 +274,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
258
274
  return Object.assign(queueEvents, {
259
275
  store,
260
276
  push,
277
+ pushAndWait,
261
278
  drain: (cb: any) => (queue.drain = cb),
262
279
  empty: (cb: any) => (queue.empty = cb),
263
280
  saturated: (cb: any) => (queue.saturated = cb),
@@ -6,23 +6,24 @@ import CustomError from '../error';
6
6
  export default function createQueueStore(queue: string) {
7
7
  return {
8
8
  async isCancelled(id: string): Promise<boolean> {
9
- const job = await Job.findOne({ where: { queue, id } });
9
+ const job = await Job.findOne({ where: { queue, id }, transaction: null });
10
10
  return !!job && !!job.cancelled;
11
11
  },
12
12
  getJob(id: string): Promise<TJob | null> {
13
- return Job.findOne({ where: { queue, id } });
13
+ return Job.findOne({ where: { queue, id }, transaction: null });
14
14
  },
15
15
  getJobs(): Promise<TJob[]> {
16
- return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']] });
16
+ return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']], transaction: null });
17
17
  },
18
18
  getScheduledJobs(): Promise<TJob[]> {
19
19
  return Job.findAll({
20
20
  where: { queue, delay: { [Op.not]: -1 }, will_run_at: { [Op.lte]: Date.now() } },
21
21
  order: [['created_at', 'ASC']],
22
+ transaction: null,
22
23
  });
23
24
  },
24
25
  async updateJob(id: string, updates: Partial<TJob>): Promise<TJob> {
25
- const job = await Job.findOne({ where: { queue, id } });
26
+ const job = await Job.findOne({ where: { queue, id }, transaction: null });
26
27
  if (!job) {
27
28
  throw new CustomError('JOB_NOT_FOUND', `Job ${id} does not exist`);
28
29
  }
@@ -30,7 +31,7 @@ export default function createQueueStore(queue: string) {
30
31
  return job.update(updates);
31
32
  },
32
33
  async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
33
- const exist = await Job.findOne({ where: { queue, id } });
34
+ const exist = await Job.findOne({ where: { queue, id }, transaction: null });
34
35
  if (exist) {
35
36
  throw new CustomError('JOB_DUPLICATE', `Job ${queue}#${id} already exist`);
36
37
  }
@@ -36,9 +36,9 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
36
36
  return 'payment';
37
37
  }
38
38
 
39
- export function getPriceUintAmountByCurrency(price: TPrice, currency: TPaymentCurrency) {
39
+ export function getPriceUintAmountByCurrency(price: TPrice, currencyId: string) {
40
40
  const options = getPriceCurrencyOptions(price);
41
- const option = options.find((x) => x.currency_id === currency.id);
41
+ const option = options.find((x) => x.currency_id === currencyId);
42
42
  if (option) {
43
43
  return option.unit_amount;
44
44
  }
@@ -55,14 +55,14 @@ export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
55
55
  }
56
56
 
57
57
  // FIXME: apply coupon for discounts
58
- export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPaymentCurrency, includeFreeTrial = false) {
58
+ export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string, includeFreeTrial = false) {
59
59
  let renew = new BN(0);
60
60
 
61
61
  const total = items
62
62
  .reduce((acc, x) => {
63
63
  const price = x.upsell_price || x.price;
64
64
  if (price.type === 'recurring') {
65
- renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
65
+ renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
66
66
 
67
67
  if (includeFreeTrial) {
68
68
  return acc;
@@ -71,7 +71,7 @@ export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPayment
71
71
  return acc;
72
72
  }
73
73
  }
74
- return acc.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
74
+ return acc.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
75
75
  }, new BN(0))
76
76
  .toString();
77
77
 
@@ -99,16 +99,17 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
99
99
  }
100
100
  }
101
101
 
102
- export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currency: TPaymentCurrency, trialInDays = 0) {
102
+ export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyId: string, trialInDays = 0) {
103
103
  let setup = new BN(0);
104
104
  let subscription = new BN(0);
105
105
 
106
106
  items.forEach((x) => {
107
107
  const price = x.upsell_price || x.price;
108
- setup = setup.add(new BN(price.unit_amount).mul(new BN(x.quantity)));
108
+ const unit = getPriceUintAmountByCurrency(price, currencyId);
109
+ setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
109
110
  if (price.type === 'recurring') {
110
111
  if (trialInDays === 0) {
111
- subscription = setup.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
112
+ subscription = setup.add(new BN(unit).mul(new BN(x.quantity)));
112
113
  }
113
114
  }
114
115
  });
@@ -154,11 +155,11 @@ export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPer
154
155
  };
155
156
  }
156
157
 
157
- export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currency: TPaymentCurrency) {
158
+ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
158
159
  let amount = new BN(0);
159
160
 
160
161
  items.forEach((x) => {
161
- amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
162
+ amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currencyId)).mul(new BN(x.quantity)));
162
163
  });
163
164
 
164
165
  return {
@@ -287,7 +288,7 @@ export function canUpsell(from: TPrice, to: TPrice) {
287
288
  export function getFastCheckoutAmount(
288
289
  items: TLineItemExpanded[],
289
290
  mode: string,
290
- currency: TPaymentCurrency,
291
+ currencyId: string,
291
292
  includeFreeTrial = false,
292
293
  minimumCycle = 1
293
294
  ) {
@@ -296,7 +297,7 @@ export function getFastCheckoutAmount(
296
297
  minimumCycle = 1;
297
298
  }
298
299
 
299
- const { total, renew } = getCheckoutAmount(items, currency, includeFreeTrial);
300
+ const { total, renew } = getCheckoutAmount(items, currencyId, includeFreeTrial);
300
301
  if (mode === 'payment') {
301
302
  return total;
302
303
  }
@@ -3,3 +3,29 @@ import { component } from '@blocklet/sdk';
3
3
  export function getCustomerSubscriptionPageUrl(subscriptionId: string, locale: string = 'en') {
4
4
  return component.getUrl(`customer/subscription/${subscriptionId}?locale=${locale}`);
5
5
  }
6
+
7
+ // FIXME: make this configurable from preferences
8
+ export function getDaysUntilDue(query: Record<string, any> = {}): number | null {
9
+ const raw = query.days_until_due || process.env.PAYMENT_DAYS_UNTIL_DUE;
10
+ if (raw) {
11
+ const days = parseInt(raw, 10);
12
+ // eslint-disable-next-line no-restricted-globals
13
+ if (isNaN(days) === false) {
14
+ return days;
15
+ }
16
+ }
17
+
18
+ return null;
19
+ }
20
+
21
+ export const getDueUnit = (interval: string) => {
22
+ if (interval === 'hour') {
23
+ return 60;
24
+ }
25
+
26
+ if (interval === 'day') {
27
+ return 60 * 60;
28
+ }
29
+
30
+ return 60 * 60 * 24;
31
+ };
@@ -9,8 +9,11 @@ import dayjs from './dayjs';
9
9
 
10
10
  export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
11
11
 
12
- export const MAX_RETRY_COUNT = 18; // 2^18 seconds ~~ 3 days, total retry time: 6 days
12
+ export const MAX_SUBSCRIPTION_ITEM_COUNT = 20;
13
+
14
+ export const MAX_RETRY_COUNT = 20; // 2^20 seconds ~~ 12 days, total retry time: 24 days
13
15
  export const MIN_RETRY_MAIL = 13; // total retry time before sending first mail: 6 hours
16
+
14
17
  export const STRIPE_API_VERSION = '2023-08-16';
15
18
  export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
16
19
  export const STRIPE_EVENTS: any[] = [
@@ -27,6 +30,7 @@ export const STRIPE_EVENTS: any[] = [
27
30
  'customer.subscription.resumed',
28
31
  'customer.subscription.trial_will_end',
29
32
  'customer.subscription.updated',
33
+ 'customer.updated',
30
34
 
31
35
  'invoice.created',
32
36
  'invoice.deleted',
@@ -2,7 +2,8 @@ import { mintNftForCheckoutSession } from '../integrations/blockchain/nft';
2
2
  import { ensurePassportIssued, ensurePassportRevoked } from '../integrations/blocklet/passport';
3
3
  import { events } from '../libs/event';
4
4
  import logger from '../libs/logger';
5
- import type { CheckoutSession, Subscription } from '../store/models';
5
+ import { CheckoutSession, Price, Subscription } from '../store/models';
6
+ import { subscriptionQueue } from './subscription';
6
7
 
7
8
  // eslint-disable-next-line require-await
8
9
  export async function startCheckoutSessionQueue() {
@@ -13,11 +14,32 @@ export async function startCheckoutSessionQueue() {
13
14
  mintNftForCheckoutSession(checkoutSession.id).catch((err) => {
14
15
  logger.error('mintNftForCheckoutSession failed', { error: err, checkoutSession: checkoutSession.id });
15
16
  });
17
+
18
+ // lock prices used
19
+ Price.update(
20
+ { locked: true },
21
+ { where: { id: checkoutSession.line_items.map((x) => x.upsell_price_id || x.price_id) } }
22
+ ).catch((err) => {
23
+ logger.error('lock price on checkout session complete failed', {
24
+ error: err,
25
+ checkoutSession: checkoutSession.id,
26
+ });
27
+ });
16
28
  });
17
29
 
18
30
  events.on('customer.subscription.deleted', (subscription: Subscription) => {
19
31
  ensurePassportRevoked(subscription).catch((err) => {
20
32
  logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
21
33
  });
34
+
35
+ // FIXME: ensure invoices that are open or uncollectible are voided
36
+ });
37
+
38
+ events.on('customer.subscription.past_due', (subscription: Subscription) => {
39
+ subscriptionQueue.push({
40
+ id: `cancel-${subscription.id}`,
41
+ job: { subscriptionId: subscription.id, action: 'cancel' },
42
+ runAt: subscription.current_period_end,
43
+ });
22
44
  });
23
45
  }
@@ -13,10 +13,10 @@ import { paymentQueue } from './payment';
13
13
  type InvoiceJob = {
14
14
  invoiceId: string;
15
15
  retryOnError?: boolean;
16
+ waitForPayment?: boolean;
16
17
  };
17
18
 
18
19
  // handle invoice payment
19
- // TODO: send invoice to user with email
20
20
  export const handleInvoice = async (job: InvoiceJob) => {
21
21
  logger.info('handle invoice', job);
22
22
 
@@ -79,14 +79,16 @@ export const handleInvoice = async (job: InvoiceJob) => {
79
79
  if (invoice.payment_intent_id) {
80
80
  logger.warn(`PaymentIntent exist: ${invoice.payment_intent_id} for invoice ${job.invoiceId}`);
81
81
  paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
82
- if (paymentIntent && ['succeeded', 'canceled'].includes(paymentIntent.status) === false) {
82
+ if (paymentIntent && paymentIntent.isImmutable() === false) {
83
83
  await paymentIntent.update({ status: 'requires_capture' });
84
84
  }
85
85
  } else {
86
86
  const descriptionMap: any = {
87
87
  subscription_create: 'Subscription creation',
88
88
  subscription_cycle: 'Subscription cycle',
89
+ subscription_update: 'Subscription update',
89
90
  };
91
+ // TODO: support partial payment from user balance
90
92
  paymentIntent = await PaymentIntent.create({
91
93
  livemode: !!invoice.livemode,
92
94
  amount: invoice.total,
@@ -120,10 +122,17 @@ export const handleInvoice = async (job: InvoiceJob) => {
120
122
  }
121
123
  if (paymentIntent) {
122
124
  logger.info(`Payment job created: ${paymentIntent.id}`);
123
- paymentQueue.push({
124
- id: paymentIntent.id,
125
- job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
126
- });
125
+ if (job.waitForPayment) {
126
+ await paymentQueue.pushAndWait({
127
+ id: paymentIntent.id,
128
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
129
+ });
130
+ } else {
131
+ paymentQueue.push({
132
+ id: paymentIntent.id,
133
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
134
+ });
135
+ }
127
136
  }
128
137
  };
129
138