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.
- package/api/src/{schedule → crons}/base.ts +1 -1
- package/api/src/index.ts +7 -7
- package/api/src/integrations/stripe/handlers/customer.ts +24 -0
- package/api/src/integrations/stripe/handlers/index.ts +4 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/audit.ts +34 -28
- package/api/src/libs/payment.ts +26 -0
- package/api/src/libs/queue/index.ts +18 -1
- package/api/src/libs/queue/store.ts +6 -5
- package/api/src/libs/session.ts +13 -12
- package/api/src/libs/subscription.ts +26 -0
- package/api/src/libs/util.ts +5 -1
- package/api/src/{jobs → queues}/checkout-session.ts +11 -0
- package/api/src/{jobs → queues}/invoice.ts +15 -6
- package/api/src/{jobs → queues}/payment.ts +182 -30
- package/api/src/{jobs → queues}/subscription.ts +36 -104
- package/api/src/{jobs → queues}/webhook.ts +2 -0
- package/api/src/routes/checkout-sessions.ts +68 -19
- package/api/src/routes/connect/collect.ts +2 -2
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/routes/connect/setup.ts +2 -2
- package/api/src/routes/connect/shared.ts +94 -45
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/pricing-table.ts +2 -0
- package/api/src/routes/subscription-items.ts +1 -1
- package/api/src/routes/subscriptions.ts +439 -13
- package/api/src/store/migrate.ts +0 -1
- package/api/src/store/migrations/20231204-subupdate.ts +50 -0
- package/api/src/store/models/checkout-session.ts +4 -0
- package/api/src/store/models/customer.ts +52 -15
- package/api/src/store/models/invoice-item.ts +6 -1
- package/api/src/store/models/invoice.ts +41 -22
- package/api/src/store/models/payment-intent.ts +4 -0
- package/api/src/store/models/setup-intent.ts +4 -0
- package/api/src/store/models/subscription-item.ts +0 -4
- package/api/src/store/models/subscription.ts +77 -44
- package/api/src/store/models/types.ts +1 -0
- package/api/src/store/sequelize.ts +6 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/jest.config.js +14 -0
- package/package.json +24 -19
- package/src/components/blockchain/tx.tsx +20 -11
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/invoice/table.tsx +58 -19
- package/src/components/layout/admin.tsx +17 -5
- package/src/components/portal/invoice/list.tsx +12 -8
- package/src/components/portal/subscription/list.tsx +114 -77
- package/src/components/subscription/status.tsx +21 -19
- package/src/global.css +4 -0
- package/src/locales/en.tsx +14 -1
- package/src/locales/zh.tsx +14 -0
- package/src/pages/admin/customers/customers/detail.tsx +47 -3
- package/src/pages/admin/overview.tsx +21 -1
- package/src/pages/admin/payments/intents/detail.tsx +12 -3
- package/src/pages/customer/invoice.tsx +15 -1
- package/src/pages/customer/subscription/index.tsx +9 -2
- package/tests/api/libs/subscription.spec.ts +45 -0
- /package/api/src/{schedule → crons}/index.ts +0 -0
- /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
- /package/api/src/{jobs → queues}/event.ts +0 -0
- /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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
122
|
-
if (subscription.pause_collection
|
|
118
|
+
if (subscription.pause_collection) {
|
|
119
|
+
if (subscription.pause_collection.behavior === 'mark_uncollectible') {
|
|
123
120
|
status = 'uncollectible';
|
|
124
121
|
}
|
|
125
|
-
if (subscription.pause_collection
|
|
122
|
+
if (subscription.pause_collection.behavior === 'void') {
|
|
126
123
|
status = 'void';
|
|
127
124
|
}
|
|
128
|
-
if (subscription.pause_collection
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
272
|
-
if (
|
|
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:
|
|
227
|
+
status: EXPECTED_SUBSCRIPTION_STATUS,
|
|
296
228
|
},
|
|
297
229
|
});
|
|
298
230
|
|
|
@@ -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
|
|
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,
|
|
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({
|
|
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 });
|