payment-kit 1.13.73 → 1.13.75

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 (65) 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 +26 -0
  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 +11 -0
  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 +68 -19
  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 +2 -2
  23. package/api/src/routes/connect/shared.ts +94 -45
  24. package/api/src/routes/connect/subscribe.ts +3 -3
  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 +439 -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/models/checkout-session.ts +4 -0
  31. package/api/src/store/models/customer.ts +52 -15
  32. package/api/src/store/models/invoice-item.ts +6 -1
  33. package/api/src/store/models/invoice.ts +41 -22
  34. package/api/src/store/models/payment-intent.ts +4 -0
  35. package/api/src/store/models/setup-intent.ts +4 -0
  36. package/api/src/store/models/subscription-item.ts +0 -4
  37. package/api/src/store/models/subscription.ts +77 -44
  38. package/api/src/store/models/types.ts +1 -0
  39. package/api/src/store/sequelize.ts +6 -0
  40. package/api/third.d.ts +2 -0
  41. package/blocklet.yml +1 -1
  42. package/jest.config.js +14 -0
  43. package/package.json +24 -19
  44. package/src/components/blockchain/tx.tsx +20 -11
  45. package/src/components/checkout/form/index.tsx +1 -1
  46. package/src/components/invoice/table.tsx +58 -19
  47. package/src/components/layout/admin.tsx +17 -5
  48. package/src/components/portal/invoice/list.tsx +12 -8
  49. package/src/components/portal/subscription/list.tsx +114 -77
  50. package/src/components/subscription/status.tsx +21 -19
  51. package/src/global.css +4 -0
  52. package/src/locales/en.tsx +14 -1
  53. package/src/locales/zh.tsx +14 -0
  54. package/src/pages/admin/customers/customers/detail.tsx +47 -3
  55. package/src/pages/admin/overview.tsx +21 -1
  56. package/src/pages/admin/payments/intents/detail.tsx +12 -3
  57. package/src/pages/customer/invoice.tsx +15 -1
  58. package/src/pages/customer/subscription/index.tsx +9 -2
  59. package/tests/api/libs/subscription.spec.ts +45 -0
  60. /package/api/src/{schedule → crons}/index.ts +0 -0
  61. /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
  62. /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
  63. /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
  64. /package/api/src/{jobs → queues}/event.ts +0 -0
  65. /package/api/src/{jobs → queues}/notification.ts +0 -0
@@ -4,10 +4,12 @@ import dayjs from '../libs/dayjs';
4
4
  import CustomError from '../libs/error';
5
5
  import { events } from '../libs/event';
6
6
  import logger from '../libs/logger';
7
- import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
7
+ import { getGasPayerExtra, isBalanceSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
8
8
  import createQueue from '../libs/queue';
9
+ import { getDaysUntilDue, getDueUnit } from '../libs/subscription';
9
10
  import { MAX_RETRY_COUNT, MIN_RETRY_MAIL, getNextRetry } from '../libs/util';
10
11
  import { CheckoutSession } from '../store/models/checkout-session';
12
+ import { Customer } from '../store/models/customer';
11
13
  import { Invoice } from '../store/models/invoice';
12
14
  import { PaymentCurrency } from '../store/models/payment-currency';
13
15
  import { PaymentIntent } from '../store/models/payment-intent';
@@ -21,7 +23,7 @@ type PaymentJob = {
21
23
  retryOnError?: boolean;
22
24
  };
23
25
 
24
- export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
26
+ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent, invoiceUpdates: any = {}) => {
25
27
  let invoice;
26
28
  if (paymentIntent.invoice_id) {
27
29
  invoice = await Invoice.findByPk(paymentIntent.invoice_id);
@@ -49,8 +51,9 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
49
51
  attempt_count: invoice.attempt_count + 1,
50
52
  attempted: true,
51
53
  status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
54
+ ...invoiceUpdates,
52
55
  });
53
- logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
56
+ logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`, invoiceUpdates);
54
57
  }
55
58
 
56
59
  if (invoice.subscription_id) {
@@ -59,10 +62,15 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
59
62
  if (subscription.status === 'incomplete') {
60
63
  await subscription.update({ status: subscription.trail_end ? 'trialing' : 'active' });
61
64
  logger.info(`Subscription ${subscription.id} updated on payment done ${invoice.id}`);
62
- } else {
63
- // FIXME: possible error here
64
- await subscription.update({ status: 'active' });
65
- logger.info(`Subscription ${subscription.id} moved to active after payment done ${paymentIntent.id}`);
65
+ } else if (subscription.status === 'past_due') {
66
+ if (subscription.cancel_at_period_end && subscription.cancelation_details?.reason === 'payment_failed') {
67
+ // @ts-ignore
68
+ await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
69
+ logger.info(`Subscription ${subscription.id} moved to active after payment done ${paymentIntent.id}`);
70
+ } else {
71
+ await subscription.update({ status: 'active' });
72
+ logger.info(`Subscription ${subscription.id} moved to active after payment done ${paymentIntent.id}`);
73
+ }
66
74
  }
67
75
  }
68
76
 
@@ -84,6 +92,124 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
84
92
  }
85
93
  };
86
94
 
95
+ type Updates = {
96
+ retry: {
97
+ payment: Partial<PaymentIntent>;
98
+ invoice: Partial<Invoice>;
99
+ };
100
+ terminate: {
101
+ payment: Partial<PaymentIntent>;
102
+ invoice: Partial<Invoice>;
103
+ };
104
+ };
105
+
106
+ export const handlePaymentFailed = async (paymentIntent: PaymentIntent, invoice: Invoice, error: PaymentError) => {
107
+ const now = dayjs().unix();
108
+ const attemptCount = invoice.attempt_count + 1;
109
+
110
+ const updates: Updates = {
111
+ retry: {
112
+ payment: { status: 'requires_capture', last_payment_error: error },
113
+ invoice: {
114
+ attempt_count: attemptCount,
115
+ attempted: true,
116
+ next_payment_attempt: getNextRetry(attemptCount),
117
+ },
118
+ },
119
+ terminate: {
120
+ payment: { status: 'requires_action', last_payment_error: error },
121
+ invoice: {
122
+ status: 'uncollectible',
123
+ attempt_count: attemptCount,
124
+ attempted: true,
125
+ status_transitions: { ...invoice.status_transitions, marked_uncollectible_at: now },
126
+ },
127
+ },
128
+ };
129
+
130
+ if (!invoice.subscription_id) {
131
+ return updates.retry;
132
+ }
133
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
134
+ if (!subscription) {
135
+ return updates.retry;
136
+ }
137
+
138
+ if (subscription.status !== 'active') {
139
+ return updates.retry;
140
+ }
141
+
142
+ // check current period
143
+ if (subscription.current_period_end <= now) {
144
+ await subscription.update({
145
+ status: 'canceled',
146
+ canceled_at: now,
147
+ cancelation_details: {
148
+ comment: 'exceed_current_period',
149
+ feedback: 'other',
150
+ reason: 'payment_failed',
151
+ },
152
+ });
153
+ logger.warn('Subscription moved to canceled after retry exceeds current_period_end', {
154
+ subscription: subscription.id,
155
+ payment: paymentIntent.id,
156
+ current_period_end: subscription.current_period_end,
157
+ now,
158
+ });
159
+ return updates.terminate;
160
+ }
161
+
162
+ // check days until due
163
+ const daysUntilDue = getDaysUntilDue(subscription);
164
+ if (typeof daysUntilDue === 'number') {
165
+ const dueUnit = getDueUnit(subscription.pending_invoice_item_interval.interval);
166
+ const gracePeriodStart = subscription.current_period_start;
167
+ const graceDuration = daysUntilDue ? daysUntilDue * dueUnit : 0;
168
+ logger.debug('handlePaymentFailed.checkDue', { now, daysUntilDue, dueUnit, gracePeriodStart, graceDuration });
169
+ if (gracePeriodStart + graceDuration <= now) {
170
+ await subscription.update({
171
+ status: 'past_due',
172
+ cancel_at_period_end: true,
173
+ cancelation_details: {
174
+ comment: 'past_due',
175
+ feedback: 'other',
176
+ reason: 'payment_failed',
177
+ },
178
+ });
179
+ logger.warn('Subscription moved to past_due after payment failed', {
180
+ subscription: subscription.id,
181
+ payment: paymentIntent.id,
182
+ gracePeriodStart,
183
+ graceDuration,
184
+ dueUnit,
185
+ });
186
+ return updates.terminate;
187
+ }
188
+ }
189
+
190
+ // check max retry
191
+ if (invoice.attempt_count > MAX_RETRY_COUNT) {
192
+ await subscription.update({
193
+ status: 'past_due',
194
+ cancel_at_period_end: true,
195
+ cancelation_details: {
196
+ comment: 'exceed_max_retry',
197
+ feedback: 'other',
198
+ reason: 'payment_failed',
199
+ },
200
+ });
201
+ logger.warn('Subscription moved to past_due after max retry', {
202
+ subscription: subscription.id,
203
+ payment: paymentIntent.id,
204
+ attempt_count: invoice.attempt_count,
205
+ });
206
+
207
+ return updates.terminate;
208
+ }
209
+
210
+ return updates.retry;
211
+ };
212
+
87
213
  export const handlePayment = async (job: PaymentJob) => {
88
214
  logger.info('handle payment', job);
89
215
 
@@ -116,9 +242,16 @@ export const handlePayment = async (job: PaymentJob) => {
116
242
  return;
117
243
  }
118
244
 
245
+ const customer = await Customer.findByPk(paymentIntent.customer_id);
246
+ if (!customer) {
247
+ logger.warn(`Customer not found: ${paymentIntent.customer_id}`);
248
+ return;
249
+ }
250
+
119
251
  const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
120
252
  const paymentSettings = invoice?.payment_settings || job.paymentSettings;
121
253
  if (!paymentSettings) {
254
+ await paymentIntent.update({ status: 'requires_action' });
122
255
  logger.warn('Payment settings not found:', job);
123
256
  return;
124
257
  }
@@ -132,7 +265,38 @@ export const handlePayment = async (job: PaymentJob) => {
132
265
  const client = paymentMethod.getOcapClient();
133
266
  const payer = paymentSettings?.payment_method_options.arcblock?.payer;
134
267
 
135
- // check balance before capture
268
+ // if we can complete purchase with customer balance
269
+ const balance = isBalanceSufficientForPayment({
270
+ paymentMethod,
271
+ paymentCurrency,
272
+ customer,
273
+ amount: paymentIntent.amount,
274
+ });
275
+ if (balance.sufficient) {
276
+ const tmp = await customer.decreaseTokenBalance(paymentCurrency.id, paymentIntent.amount);
277
+ logger.info(`PaymentIntent capture done: ${paymentIntent.id} with customer balance`, tmp);
278
+ await paymentIntent.update({
279
+ status: 'succeeded',
280
+ amount: '0', // update payment intent amount to 0
281
+ amount_received: '0',
282
+ payment_details: {
283
+ arcblock: {
284
+ tx_hash: '',
285
+ payer: payer as string,
286
+ },
287
+ },
288
+ });
289
+
290
+ await handlePaymentSucceed(paymentIntent, {
291
+ starting_token_balance: tmp.starting,
292
+ ending_token_balance: tmp.ending,
293
+ });
294
+ return;
295
+ }
296
+
297
+ // FIXME: support partial payment from balance
298
+
299
+ // check balance before capture with transaction
136
300
  result = await isDelegationSufficientForPayment({
137
301
  paymentMethod,
138
302
  paymentCurrency,
@@ -208,7 +372,10 @@ export const handlePayment = async (job: PaymentJob) => {
208
372
  });
209
373
  }
210
374
  } else if (invoice) {
375
+ // This means we have tried to capture this invoice before, since the retry is managed by invoice queue
211
376
  const attemptCount = invoice.attempt_count + 1;
377
+
378
+ // 只有在重试次数超过阈值的时候才发送邮件,不然邮件频率太高了,初次邮件时首次失败 6 小时后
212
379
  if (attemptCount >= MIN_RETRY_MAIL && invoice.billing_reason === 'subscription_cycle') {
213
380
  events.emit('customer.subscription.renew_failed', {
214
381
  invoice,
@@ -216,34 +383,19 @@ export const handlePayment = async (job: PaymentJob) => {
216
383
  });
217
384
  }
218
385
 
219
- if (invoice.attempt_count > MAX_RETRY_COUNT) {
220
- await paymentIntent.update({ status: 'requires_action', last_payment_error: error });
221
- await invoice.update({
222
- status: 'uncollectible',
223
- attempt_count: attemptCount,
224
- attempted: true,
225
- status_transitions: { ...invoice.status_transitions, marked_uncollectible_at: dayjs().unix() },
226
- });
386
+ const updates = await handlePaymentFailed(paymentIntent, invoice, error);
387
+ await paymentIntent.update(updates.payment);
388
+ await invoice.update(updates.invoice);
227
389
 
228
- // FIXME: send email to customer, pause subscription
229
- logger.error('PaymentIntent capture failed after max retry', { id: paymentIntent.id });
230
- } else {
231
- const retryAt = getNextRetry(attemptCount);
232
-
233
- await paymentIntent.update({ status: 'requires_capture', last_payment_error: error });
234
- await invoice.update({
235
- attempt_count: attemptCount,
236
- attempted: true,
237
- next_payment_attempt: retryAt,
238
- });
239
- logger.error('PaymentIntent capture retry scheduled', { id: paymentIntent.id, retryAt });
240
-
241
- // reschedule next attempt
390
+ // reschedule next attempt
391
+ const retryAt = updates.invoice.next_payment_attempt;
392
+ if (retryAt) {
242
393
  paymentQueue.push({
243
394
  id: paymentIntent.id,
244
395
  job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
245
396
  runAt: retryAt,
246
397
  });
398
+ logger.error('PaymentIntent capture retry scheduled', { id: paymentIntent.id, retryAt });
247
399
  }
248
400
  }
249
401
  }
@@ -1,14 +1,13 @@
1
- import { BN } from '@ocap/util';
2
1
  import type { LiteralUnion } from 'type-fest';
3
2
 
4
3
  import dayjs from '../libs/dayjs';
5
4
  import logger from '../libs/logger';
6
5
  import createQueue from '../libs/queue';
7
6
  import { getStatementDescriptor, getSubscriptionCycleAmount, getSubscriptionCycleSetup } from '../libs/session';
7
+ import { ensureInvoiceAndItems } from '../routes/connect/shared';
8
8
  import { PaymentCurrency, PaymentMethod, UsageRecord } from '../store/models';
9
9
  import { Customer } from '../store/models/customer';
10
10
  import { Invoice } from '../store/models/invoice';
11
- import { InvoiceItem } from '../store/models/invoice-item';
12
11
  import { Price } from '../store/models/price';
13
12
  import { Subscription } from '../store/models/subscription';
14
13
  import { SubscriptionItem } from '../store/models/subscription-item';
@@ -19,6 +18,8 @@ type SubscriptionJob = {
19
18
  action?: LiteralUnion<'cycle' | 'cancel' | 'pause' | 'resume', string>;
20
19
  };
21
20
 
21
+ const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
22
+
22
23
  // generate invoice for subscription periodically
23
24
  export const handleSubscription = async (job: SubscriptionJob) => {
24
25
  logger.info('handle subscription', job);
@@ -28,7 +29,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
28
29
  logger.warn(`Subscription not found: ${job.subscriptionId}`);
29
30
  return;
30
31
  }
31
- if (['trialing', 'active', 'paused'].includes(subscription.status) === false) {
32
+ if (EXPECTED_SUBSCRIPTION_STATUS.includes(subscription.status) === false) {
32
33
  logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
33
34
  return;
34
35
  }
@@ -41,7 +42,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
41
42
  const now = dayjs().unix();
42
43
 
43
44
  // Do we need to cancel the subscription
44
- if (subscription.status === 'active') {
45
+ if (subscription.isImmutable() === false) {
45
46
  if (subscription.cancel_at_period_end) {
46
47
  await subscription.update({ status: 'canceled', canceled_at: now });
47
48
  logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
@@ -55,11 +56,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
55
56
  }
56
57
 
57
58
  // Do we need to resume the subscription
58
- if (
59
- subscription.status === 'paused' &&
60
- subscription.pause_collection?.resumes_at &&
61
- subscription.pause_collection?.resumes_at <= now
62
- ) {
59
+ if (subscription.pause_collection?.resumes_at && subscription.pause_collection?.resumes_at <= now) {
63
60
  await subscription.update({ status: 'active', pause_collection: undefined });
64
61
  logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
65
62
  }
@@ -118,14 +115,14 @@ export const handleSubscription = async (job: SubscriptionJob) => {
118
115
 
119
116
  // set invoice status if subscription paused
120
117
  let status = 'open';
121
- if (subscription.status === 'paused') {
122
- if (subscription.pause_collection?.behavior === 'mark_uncollectible') {
118
+ if (subscription.pause_collection) {
119
+ if (subscription.pause_collection.behavior === 'mark_uncollectible') {
123
120
  status = 'uncollectible';
124
121
  }
125
- if (subscription.pause_collection?.behavior === 'void') {
122
+ if (subscription.pause_collection.behavior === 'void') {
126
123
  status = 'void';
127
124
  }
128
- if (subscription.pause_collection?.behavior === 'keep_as_draft') {
125
+ if (subscription.pause_collection.behavior === 'keep_as_draft') {
129
126
  status = 'draft';
130
127
  }
131
128
  }
@@ -166,95 +163,30 @@ export const handleSubscription = async (job: SubscriptionJob) => {
166
163
  })
167
164
  );
168
165
 
169
- const amount = getSubscriptionCycleAmount(expandedItems, currency);
170
-
171
- // create invoice
172
- const invoice = await Invoice.create({
173
- livemode: subscription.livemode,
174
- number: await customer.getInvoiceNumber(),
175
- description: 'Subscription cycle',
176
- statement_descriptor: getStatementDescriptor(expandedItems),
177
- period_start: setup.period.start,
178
- period_end: setup.period.end,
179
-
180
- auto_advance: true,
181
- paid: false,
182
- paid_out_of_band: false,
183
-
184
- status,
185
- collection_method: 'charge_automatically',
186
- billing_reason: 'subscription_cycle',
187
-
188
- currency_id: subscription.currency_id,
189
- customer_id: customer.id,
190
- payment_intent_id: '',
191
- subscription_id: subscription?.id,
192
- checkout_session_id: '',
193
-
194
- subtotal: amount.total,
195
- subtotal_excluding_tax: amount.total,
196
- tax: '0',
197
- total: amount.total,
198
- amount_due: amount.total,
199
- amount_paid: '0',
200
- amount_remaining: amount.total,
201
- amount_shipping: '0',
202
-
203
- starting_balance: '0',
204
- ending_balance: '0',
205
-
206
- attempt_count: 0,
207
- attempted: false,
208
- // next_payment_attempt: undefined,
209
-
210
- custom_fields: [],
211
- customer_address: customer.address,
212
- customer_email: customer.email,
213
- customer_name: customer.name,
214
- customer_phone: customer.phone,
215
-
216
- discounts: [],
217
- total_discount_amounts: [],
218
-
219
- due_date: undefined, // The date on which payment for this invoice is due
220
- effective_at: dayjs().unix(), // The date when this invoice is in effect
221
- status_transitions: {
222
- finalized_at: dayjs().unix(),
223
- },
224
-
225
- payment_settings: subscription.payment_settings,
226
- default_payment_method_id: subscription.default_payment_method_id as string,
227
-
228
- account_country: '',
229
- account_name: '',
230
- metadata: {},
166
+ const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
167
+
168
+ const { invoice } = await ensureInvoiceAndItems({
169
+ customer,
170
+ subscription,
171
+ trailing: false,
172
+ metered: true,
173
+ lineItems: expandedItems,
174
+ props: {
175
+ livemode: subscription.livemode,
176
+ description: 'Subscription cycle',
177
+ statement_descriptor: getStatementDescriptor(expandedItems),
178
+ period_start: setup.period.start,
179
+ period_end: setup.period.end,
180
+ auto_advance: true,
181
+ status,
182
+ billing_reason: 'subscription_cycle',
183
+ currency_id: subscription.currency_id,
184
+ total: amount.total,
185
+ payment_settings: subscription.payment_settings,
186
+ default_payment_method_id: subscription.default_payment_method_id,
187
+ metadata: {},
188
+ } as Invoice,
231
189
  });
232
- logger.info(`Invoice created for subscription ${subscription.id}: ${invoice.id}`);
233
-
234
- // create invoice items
235
- await Promise.all(
236
- expandedItems.map((x: any) =>
237
- InvoiceItem.create({
238
- livemode: subscription.livemode,
239
- amount: new BN(x.price.unit_amount).mul(new BN(x.quantity)).toString(),
240
- quantity: x.quantity,
241
- description: x.price.product.name,
242
- period: { start: setup.period.start, end: setup.period.end },
243
- currency_id: subscription.currency_id,
244
- customer_id: customer.id,
245
- price_id: x.price_id,
246
- invoice_id: invoice.id,
247
- subscription_id: subscription.id,
248
- subscription_item_id: subscriptionItems.find((si) => si.price_id === x.price_id)?.id,
249
- discountable: false,
250
- discounts: [],
251
- discount_amounts: [],
252
- proration: false,
253
- proration_details: {},
254
- metadata: {},
255
- })
256
- )
257
- );
258
190
 
259
191
  // schedule invoice job
260
192
  invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
@@ -268,8 +200,8 @@ export const handleSubscription = async (job: SubscriptionJob) => {
268
200
  });
269
201
  logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
270
202
 
271
- // schedule next billing cycle if we are not cancelling
272
- if (!subscription.cancel_at_period_end) {
203
+ // schedule next billing cycle if we are not in terminal state
204
+ if (subscription.isActive()) {
273
205
  subscriptionQueue.push({
274
206
  id: subscription.id,
275
207
  job: { subscriptionId: subscription.id, action: 'cycle' },
@@ -292,7 +224,7 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
292
224
  export const startSubscriptionQueue = async () => {
293
225
  const subscriptions = await Subscription.findAll({
294
226
  where: {
295
- status: ['trialing', 'active', 'paused'],
227
+ status: EXPECTED_SUBSCRIPTION_STATUS,
296
228
  },
297
229
  });
298
230
 
@@ -101,6 +101,8 @@ export const handleWebhook = async (job: WebhookJob) => {
101
101
  runAt: getNextRetry(retryCount),
102
102
  });
103
103
  });
104
+ } else {
105
+ await event.decrement('pending_webhooks');
104
106
  }
105
107
  }
106
108
  };
@@ -16,12 +16,9 @@ import { checkPassportForPaymentLink } from '../integrations/blocklet/passport';
16
16
  import { handleStripePaymentSucceed } from '../integrations/stripe/handlers/payment-intent';
17
17
  import { handleStripeSubscriptionSucceed } from '../integrations/stripe/handlers/subscription';
18
18
  import { ensureStripePaymentIntent, ensureStripeSubscription } from '../integrations/stripe/resource';
19
- import { invoiceQueue } from '../jobs/invoice';
20
- import { paymentQueue } from '../jobs/payment';
21
- import { subscriptionQueue } from '../jobs/subscription';
22
19
  import dayjs from '../libs/dayjs';
23
20
  import logger from '../libs/logger';
24
- import { isDelegationSufficientForPayment } from '../libs/payment';
21
+ import { isBalanceSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
25
22
  import { authenticate } from '../libs/security';
26
23
  import {
27
24
  canUpsell,
@@ -35,11 +32,15 @@ import {
35
32
  getSupportedPaymentMethods,
36
33
  isLineItemAligned,
37
34
  } from '../libs/session';
35
+ import { getDaysUntilDue } from '../libs/subscription';
38
36
  import { createCodeGenerator, formatMetadata, getMetadataFromQuery } from '../libs/util';
37
+ import { invoiceQueue } from '../queues/invoice';
38
+ import { paymentQueue } from '../queues/payment';
39
+ import { subscriptionQueue } from '../queues/subscription';
39
40
  import type { TPriceExpanded, TProductExpanded } from '../store/models';
40
41
  import { CheckoutSession } from '../store/models/checkout-session';
41
42
  import { Customer } from '../store/models/customer';
42
- import { PaymentCurrency, TPaymentCurrency } from '../store/models/payment-currency';
43
+ import { PaymentCurrency } from '../store/models/payment-currency';
43
44
  import { PaymentIntent } from '../store/models/payment-intent';
44
45
  import { PaymentLink } from '../store/models/payment-link';
45
46
  import { PaymentMethod } from '../store/models/payment-method';
@@ -186,9 +187,8 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
186
187
 
187
188
  export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession) {
188
189
  const items = await Price.expand(checkoutSession.line_items);
189
- const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
190
190
  const includeTrial = !!checkoutSession.subscription_data?.trial_period_days;
191
- const amount = getCheckoutAmount(items, currency as TPaymentCurrency, includeTrial);
191
+ const amount = getCheckoutAmount(items, checkoutSession.currency_id, includeTrial);
192
192
  return {
193
193
  amount_subtotal: amount.subtotal,
194
194
  amount_total: amount.total,
@@ -333,6 +333,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
333
333
  raw.metadata = {
334
334
  ...link.metadata,
335
335
  ...getMetadataFromQuery(req.query),
336
+ days_until_due: getDaysUntilDue(req.query),
336
337
  passport: await checkPassportForPaymentLink(link),
337
338
  preview: '1',
338
339
  };
@@ -341,6 +342,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
341
342
  raw.metadata = {
342
343
  ...link.metadata,
343
344
  ...getMetadataFromQuery(req.query),
345
+ days_until_due: getDaysUntilDue(req.query),
344
346
  passport: await checkPassportForPaymentLink(link),
345
347
  };
346
348
  }
@@ -445,7 +447,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
445
447
  // always update payment amount in case currency has changed
446
448
  const lineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
447
449
  const trialInDays = checkoutSession.subscription_data?.trial_period_days || 0;
448
- const amount = getCheckoutAmount(lineItems, paymentCurrency, !!trialInDays);
450
+ const amount = getCheckoutAmount(lineItems, paymentCurrency.id, !!trialInDays);
449
451
  await checkoutSession.update({
450
452
  amount_subtotal: amount.subtotal,
451
453
  amount_total: amount.total,
@@ -627,7 +629,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
627
629
  pending_setup_intent: setupIntent?.id,
628
630
  });
629
631
  } else {
630
- const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency, trialInDays);
632
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays);
631
633
  subscription = await Subscription.create({
632
634
  livemode: !!checkoutSession.livemode,
633
635
  currency_id: paymentCurrency.id,
@@ -649,6 +651,9 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
649
651
  default_payment_method_id: paymentMethod.id,
650
652
  cancel_at_period_end: false,
651
653
  collection_method: 'charge_automatically',
654
+ proration_behavior: 'none',
655
+ payment_behavior: 'default_incomplete',
656
+ days_until_due: checkoutSession.metadata?.days_until_due,
652
657
  metadata: checkoutSession.metadata as any,
653
658
  });
654
659
 
@@ -683,22 +688,56 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
683
688
  }
684
689
  }
685
690
 
691
+ let isPaymentFromBalance = false;
692
+ const fastCheckoutAmount = getFastCheckoutAmount(
693
+ lineItems,
694
+ checkoutSession.mode,
695
+ paymentCurrency.id,
696
+ !!trialInDays
697
+ );
698
+ const paymentSettings = {
699
+ payment_method_types: checkoutSession.payment_method_types,
700
+ payment_method_options: {
701
+ arcblock: { payer: customer.did },
702
+ },
703
+ };
704
+
705
+ // if we can complete purchase with customer balance
706
+ const balance = isBalanceSufficientForPayment({
707
+ paymentMethod,
708
+ paymentCurrency,
709
+ customer,
710
+ amount: fastCheckoutAmount,
711
+ });
712
+ if (balance.sufficient) {
713
+ if (checkoutSession.mode === 'payment' && paymentIntent) {
714
+ await paymentIntent.update({ status: 'requires_capture' });
715
+ logger.info(`CheckoutSession ${checkoutSession.id} will pay from balance ${paymentIntent?.id}`);
716
+
717
+ const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, paymentIntent });
718
+ if (invoice) {
719
+ await invoice.update({ auto_advance: true, payment_settings: paymentSettings });
720
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
721
+ } else {
722
+ paymentQueue.push({
723
+ id: paymentIntent.id,
724
+ job: { paymentIntentId: paymentIntent.id, paymentSettings, retryOnError: false },
725
+ });
726
+ }
727
+
728
+ isPaymentFromBalance = true;
729
+ }
730
+ }
731
+
686
732
  // if we can complete purchase without any wallet interaction
687
- const fastCheckoutAmount = getFastCheckoutAmount(lineItems, checkoutSession.mode, paymentCurrency, !!trialInDays);
688
733
  const delegation = await isDelegationSufficientForPayment({
689
734
  paymentMethod,
690
735
  paymentCurrency,
691
736
  userDid: customer.did,
692
737
  amount: fastCheckoutAmount,
693
738
  });
694
- if (delegation.sufficient) {
695
- const paymentSettings = {
696
- payment_method_types: checkoutSession.payment_method_types,
697
- payment_method_options: {
698
- arcblock: { payer: delegation.delegator as string },
699
- },
700
- };
701
739
 
740
+ if (delegation.sufficient) {
702
741
  // all subscription payments are done after delegation
703
742
  if (checkoutSession.mode === 'subscription' && subscription) {
704
743
  await subscription.update({ payment_settings: paymentSettings });
@@ -713,7 +752,8 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
713
752
  runAt: subscription.trail_end || subscription.current_period_end,
714
753
  });
715
754
  }
716
- if (checkoutSession.mode === 'payment' && paymentIntent) {
755
+ if (checkoutSession.mode === 'payment' && paymentIntent && !isPaymentFromBalance) {
756
+ logger.info(`CheckoutSession ${checkoutSession.id} will pay from delegation ${paymentIntent?.id}`);
717
757
  const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, paymentIntent });
718
758
  if (invoice) {
719
759
  await invoice.update({ auto_advance: true, payment_settings: paymentSettings });
@@ -781,7 +821,16 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
781
821
  }
782
822
  }
783
823
 
784
- return res.json({ paymentIntent, setupIntent, stripeContext, subscription, checkoutSession, customer, delegation });
824
+ return res.json({
825
+ paymentIntent,
826
+ setupIntent,
827
+ stripeContext,
828
+ subscription,
829
+ checkoutSession,
830
+ customer,
831
+ delegation,
832
+ balance,
833
+ });
785
834
  } catch (err) {
786
835
  console.error(err);
787
836
  res.status(500).json({ code: err.code, error: err.message });