payment-kit 1.13.130 → 1.13.132
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/index.ts +2 -0
- package/api/src/libs/payment.ts +30 -1
- package/api/src/libs/session.ts +2 -2
- package/api/src/libs/subscription.ts +134 -1
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +2 -0
- package/api/src/queues/refund.ts +212 -0
- package/api/src/routes/checkout-sessions.ts +2 -2
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/refunds.ts +93 -0
- package/api/src/routes/subscriptions.ts +116 -130
- package/api/src/store/migrations/20240205-refund.ts +10 -0
- package/api/src/store/models/index.ts +12 -0
- package/api/src/store/models/refund.ts +226 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/components/customer/link.tsx +16 -0
- package/src/components/info-card.tsx +1 -1
- package/src/components/invoice/list.tsx +3 -3
- package/src/components/payment-intent/list.tsx +3 -3
- package/src/components/refund/actions.tsx +44 -0
- package/src/components/refund/list.tsx +207 -0
- package/src/components/subscription/actions/cancel.tsx +62 -2
- package/src/components/subscription/list.tsx +2 -1
- package/src/components/subscription/status.tsx +1 -3
- package/src/global.css +0 -3
- package/src/libs/util.ts +0 -50
- package/src/locales/en.tsx +10 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/billing/invoices/detail.tsx +3 -6
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -2
- package/src/pages/admin/customers/customers/index.tsx +12 -1
- package/src/pages/admin/payments/index.tsx +7 -0
- package/src/pages/admin/payments/intents/detail.tsx +11 -7
- package/src/pages/admin/payments/refunds/detail.tsx +223 -0
- package/src/pages/admin/payments/refunds/index.tsx +5 -0
- package/src/pages/customer/invoice.tsx +1 -3
- package/src/pages/customer/subscription/detail.tsx +1 -2
- package/src/pages/customer/subscription/update.tsx +4 -4
- package/src/components/blockchain/tx.tsx +0 -77
package/api/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { startEventQueue } from './queues/event';
|
|
|
21
21
|
import { startInvoiceQueue } from './queues/invoice';
|
|
22
22
|
import { startNotificationQueue } from './queues/notification';
|
|
23
23
|
import { startPaymentQueue } from './queues/payment';
|
|
24
|
+
import { startRefundQueue } from './queues/refund';
|
|
24
25
|
import { startSubscriptionQueue } from './queues/subscription';
|
|
25
26
|
import routes from './routes';
|
|
26
27
|
import collectHandlers from './routes/connect/collect';
|
|
@@ -108,6 +109,7 @@ export const server = app.listen(port, (err?: any) => {
|
|
|
108
109
|
startEventQueue().then(() => logger.info('event queue started'));
|
|
109
110
|
startCheckoutSessionQueue().then(() => logger.info('checkoutSession queue started'));
|
|
110
111
|
startNotificationQueue().then(() => logger.info('notification queue started'));
|
|
112
|
+
startRefundQueue().then(() => logger.info('refund queue started'));
|
|
111
113
|
|
|
112
114
|
if (process.env.BLOCKLET_MODE === 'production') {
|
|
113
115
|
ensureWebhookRegistered().catch(console.error);
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -104,7 +104,7 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
104
104
|
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
export function
|
|
107
|
+
export function isCreditSufficientForPayment(args: {
|
|
108
108
|
paymentMethod: PaymentMethod;
|
|
109
109
|
paymentCurrency: TPaymentCurrency;
|
|
110
110
|
customer: TCustomer;
|
|
@@ -255,3 +255,32 @@ export async function getTokenLimitsForDelegation(
|
|
|
255
255
|
|
|
256
256
|
return [entry];
|
|
257
257
|
}
|
|
258
|
+
|
|
259
|
+
export async function isBalanceSufficientForPayment(args: {
|
|
260
|
+
paymentMethod: PaymentMethod;
|
|
261
|
+
paymentCurrency: TPaymentCurrency;
|
|
262
|
+
amount: string;
|
|
263
|
+
}): Promise<SufficientForPaymentResult> {
|
|
264
|
+
const { paymentCurrency, paymentMethod, amount } = args;
|
|
265
|
+
const tokenAddress = paymentCurrency.contract as string;
|
|
266
|
+
|
|
267
|
+
if (paymentMethod.type === 'arcblock') {
|
|
268
|
+
const client = paymentMethod.getOcapClient();
|
|
269
|
+
const { tokens } = await client.getAccountTokens({ address: wallet.address, token: tokenAddress });
|
|
270
|
+
const [token] = tokens;
|
|
271
|
+
if (!token) {
|
|
272
|
+
return { sufficient: false, reason: 'NO_TOKEN' };
|
|
273
|
+
}
|
|
274
|
+
if (new BN(token.balance).lt(new BN(amount))) {
|
|
275
|
+
return { sufficient: false, reason: 'NO_ENOUGH_TOKEN', token };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { sufficient: true, token };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (paymentMethod.type === 'stripe') {
|
|
282
|
+
return { sufficient: false, reason: 'NOT_SUPPORTED' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
286
|
+
}
|
package/api/src/libs/session.ts
CHANGED
|
@@ -60,13 +60,13 @@ export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string
|
|
|
60
60
|
const total = items
|
|
61
61
|
.reduce((acc, x) => {
|
|
62
62
|
const price = x.upsell_price || x.price;
|
|
63
|
-
if (price
|
|
63
|
+
if (price?.type === 'recurring') {
|
|
64
64
|
renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
|
|
65
65
|
|
|
66
66
|
if (includeFreeTrial) {
|
|
67
67
|
return acc;
|
|
68
68
|
}
|
|
69
|
-
if (price
|
|
69
|
+
if (price?.recurring?.usage_type === 'metered') {
|
|
70
70
|
return acc;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import component from '@blocklet/sdk/lib/component';
|
|
2
2
|
import { BN } from '@ocap/util';
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
Customer,
|
|
6
|
+
Invoice,
|
|
7
|
+
InvoiceItem,
|
|
8
|
+
Price,
|
|
9
|
+
PriceRecurring,
|
|
10
|
+
Subscription,
|
|
11
|
+
SubscriptionItem,
|
|
12
|
+
TLineItemExpanded,
|
|
13
|
+
} from '../store/models';
|
|
5
14
|
import dayjs from './dayjs';
|
|
15
|
+
import logger from './logger';
|
|
6
16
|
import { getPriceUintAmountByCurrency, getRecurringPeriod } from './session';
|
|
7
17
|
|
|
8
18
|
export function getCustomerSubscriptionPageUrl(subscriptionId: string, locale: string = 'en') {
|
|
@@ -129,3 +139,126 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
|
|
|
129
139
|
total: amount.toString(),
|
|
130
140
|
};
|
|
131
141
|
}
|
|
142
|
+
|
|
143
|
+
export async function createProration(
|
|
144
|
+
subscription: Subscription,
|
|
145
|
+
setup: ReturnType<typeof getSubscriptionCreateSetup>,
|
|
146
|
+
anchor: number
|
|
147
|
+
) {
|
|
148
|
+
// FIXME: should we enforce cycle invoices here?
|
|
149
|
+
const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
|
|
150
|
+
if (!lastInvoice) {
|
|
151
|
+
throw new Error('Subscription should have latest invoice when create proration');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
155
|
+
if (!customer) {
|
|
156
|
+
throw new Error('Subscription should have customer when create proration');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 1. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
|
|
160
|
+
const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id, proration: false } });
|
|
161
|
+
const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
|
|
162
|
+
const prorationItems = invoiceItemsExpanded.filter(
|
|
163
|
+
(x) => x.price.type === 'recurring' && x.price.recurring?.usage_type === 'licensed'
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// 2. calculate proration args based on the filtered invoice items
|
|
167
|
+
const precision = 10000;
|
|
168
|
+
const prorationStart = lastInvoice.period_start;
|
|
169
|
+
const prorationEnd = lastInvoice.period_end;
|
|
170
|
+
if (anchor > prorationEnd) {
|
|
171
|
+
throw new Error('Subscription proration anchor should not be larger than prorationEnd');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const prorationRate = Math.ceil(((prorationEnd - anchor) / (prorationEnd - prorationStart)) * precision);
|
|
175
|
+
let unused = new BN(0);
|
|
176
|
+
const prorations = await Promise.all(
|
|
177
|
+
prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
|
|
178
|
+
const unitAmount = getPriceUintAmountByCurrency(x.price, subscription.currency_id);
|
|
179
|
+
const amount = new BN(unitAmount)
|
|
180
|
+
.mul(new BN(x.quantity))
|
|
181
|
+
.mul(new BN(prorationRate))
|
|
182
|
+
.div(new BN(precision))
|
|
183
|
+
.toString();
|
|
184
|
+
logger.info('subscription proration item', {
|
|
185
|
+
subscription: subscription.id,
|
|
186
|
+
invoice: x.invoice_id,
|
|
187
|
+
invoiceItem: x.id,
|
|
188
|
+
amount,
|
|
189
|
+
});
|
|
190
|
+
unused = unused.add(new BN(amount));
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
price_id: x.price_id,
|
|
194
|
+
amount: `-${amount}`,
|
|
195
|
+
quantity: x.quantity,
|
|
196
|
+
// @ts-ignore
|
|
197
|
+
description: `Unused time on ${x.price.product.name} after ${dayjs().format('lll')}`,
|
|
198
|
+
period: {
|
|
199
|
+
start: lastInvoice.period_start,
|
|
200
|
+
end: lastInvoice.period_end,
|
|
201
|
+
},
|
|
202
|
+
proration: true,
|
|
203
|
+
proration_details: {
|
|
204
|
+
credited_items: {
|
|
205
|
+
invoice_id: lastInvoice.id,
|
|
206
|
+
// @ts-ignore
|
|
207
|
+
invoice_line_items: [x.id],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
logger.info('subscription prorations created', {
|
|
214
|
+
subscription: subscription.id,
|
|
215
|
+
prorations,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 5. adjust invoice total && update customer token balance
|
|
219
|
+
const total = setup.amount.setup;
|
|
220
|
+
let due = setup.amount.setup;
|
|
221
|
+
let newCredit = '0';
|
|
222
|
+
let appliedCredit = '0';
|
|
223
|
+
if (new BN(total).gte(unused)) {
|
|
224
|
+
// Proration amount is less than total, all proration are used,
|
|
225
|
+
due = new BN(total).sub(unused).toString();
|
|
226
|
+
// Besides, we need to try to apply customer credit
|
|
227
|
+
appliedCredit = customer.getBalanceToApply(subscription.currency_id, due);
|
|
228
|
+
due = new BN(due).sub(new BN(appliedCredit)).toString();
|
|
229
|
+
} else {
|
|
230
|
+
// Proration amount is greater than total, we need to increase customer credit
|
|
231
|
+
newCredit = unused.sub(new BN(total)).toString();
|
|
232
|
+
due = '0';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
logger.info('subscription proration result', {
|
|
236
|
+
subscription: subscription.id,
|
|
237
|
+
prorationStart,
|
|
238
|
+
prorationEnd,
|
|
239
|
+
prorationRate,
|
|
240
|
+
unused: unused.toString(),
|
|
241
|
+
total,
|
|
242
|
+
due,
|
|
243
|
+
newCredit,
|
|
244
|
+
appliedCredit,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
lastInvoice,
|
|
249
|
+
total,
|
|
250
|
+
due,
|
|
251
|
+
used: new BN(lastInvoice.amount_due).sub(unused).toString(),
|
|
252
|
+
unused: unused.toString(),
|
|
253
|
+
prorations,
|
|
254
|
+
newCredit,
|
|
255
|
+
appliedCredit,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number) {
|
|
260
|
+
const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
261
|
+
const expanded = await Price.expand(items.map((x) => x.toJSON()));
|
|
262
|
+
const setup = getSubscriptionCreateSetup(expanded, subscription.currency_id, 0);
|
|
263
|
+
return createProration(subscription, setup, anchor);
|
|
264
|
+
}
|
|
@@ -94,6 +94,7 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
94
94
|
subscription_create: 'Subscription creation',
|
|
95
95
|
subscription_cycle: 'Subscription cycle',
|
|
96
96
|
subscription_update: 'Subscription update',
|
|
97
|
+
subscription_threshold: 'Subscription threshold',
|
|
97
98
|
};
|
|
98
99
|
// TODO: support partial payment from user balance
|
|
99
100
|
paymentIntent = await PaymentIntent.create({
|
|
@@ -329,6 +329,7 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
329
329
|
// @ts-ignore
|
|
330
330
|
value: {
|
|
331
331
|
appId: wallet.address,
|
|
332
|
+
reason: invoice ? invoice.billing_reason : 'payment',
|
|
332
333
|
paymentIntentId: paymentIntent.id,
|
|
333
334
|
},
|
|
334
335
|
},
|
|
@@ -346,6 +347,7 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
346
347
|
|
|
347
348
|
await paymentIntent.update({
|
|
348
349
|
status: 'succeeded',
|
|
350
|
+
last_payment_error: null,
|
|
349
351
|
amount_received: paymentIntent.amount,
|
|
350
352
|
payment_details: {
|
|
351
353
|
arcblock: {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { wallet } from '../libs/auth';
|
|
2
|
+
import CustomError from '../libs/error';
|
|
3
|
+
import { events } from '../libs/event';
|
|
4
|
+
import logger from '../libs/logger';
|
|
5
|
+
import { getGasPayerExtra, isBalanceSufficientForPayment } from '../libs/payment';
|
|
6
|
+
import createQueue from '../libs/queue';
|
|
7
|
+
import { MAX_RETRY_COUNT, getNextRetry } from '../libs/util';
|
|
8
|
+
import { Customer } from '../store/models/customer';
|
|
9
|
+
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
10
|
+
import { PaymentMethod } from '../store/models/payment-method';
|
|
11
|
+
import { Refund } from '../store/models/refund';
|
|
12
|
+
import type { PaymentError } from '../store/models/types';
|
|
13
|
+
|
|
14
|
+
type RefundJob = {
|
|
15
|
+
refundId: string;
|
|
16
|
+
retryOnError?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Updates = {
|
|
20
|
+
retry: {
|
|
21
|
+
refund: Partial<Refund>;
|
|
22
|
+
};
|
|
23
|
+
terminate: {
|
|
24
|
+
refund: Partial<Refund>;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const handleRefundFailed = (refund: Refund, error: PaymentError) => {
|
|
29
|
+
const attemptCount = refund.attempt_count + 1;
|
|
30
|
+
const updates: Updates = {
|
|
31
|
+
retry: {
|
|
32
|
+
refund: {
|
|
33
|
+
status: 'pending',
|
|
34
|
+
last_attempt_error: error,
|
|
35
|
+
attempt_count: attemptCount,
|
|
36
|
+
attempted: true,
|
|
37
|
+
next_attempt: getNextRetry(attemptCount),
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
terminate: {
|
|
41
|
+
refund: {
|
|
42
|
+
status: 'requires_action',
|
|
43
|
+
last_attempt_error: error,
|
|
44
|
+
attempt_count: attemptCount,
|
|
45
|
+
attempted: true,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// check max retry
|
|
51
|
+
if (refund.attempt_count > MAX_RETRY_COUNT) {
|
|
52
|
+
return updates.terminate;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return updates.retry;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const handleRefund = async (job: RefundJob) => {
|
|
59
|
+
logger.info('handle refund', job);
|
|
60
|
+
|
|
61
|
+
const refund = await Refund.findByPk(job.refundId);
|
|
62
|
+
if (!refund) {
|
|
63
|
+
logger.warn(`refund not found: ${job.refundId}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (['pending'].includes(refund.status) === false) {
|
|
68
|
+
logger.warn(`refund status not expected: ${refund.status}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// check max retry before doing any hard work
|
|
73
|
+
if (refund.attempt_count > MAX_RETRY_COUNT) {
|
|
74
|
+
logger.info(`refund transfer aborted since max retry exceeded: ${refund.id}`);
|
|
75
|
+
const updates = handleRefundFailed(refund, {
|
|
76
|
+
type: 'card_error',
|
|
77
|
+
code: 'max_retry_exceeded',
|
|
78
|
+
message: 'max_retry_exceeded',
|
|
79
|
+
});
|
|
80
|
+
await refund.update(updates.refund);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const paymentCurrency = await PaymentCurrency.findByPk(refund.currency_id);
|
|
85
|
+
if (!paymentCurrency) {
|
|
86
|
+
logger.warn(`PaymentCurrency not found: ${refund.currency_id}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
90
|
+
if (!paymentMethod) {
|
|
91
|
+
logger.warn(`PaymentMethod not found: ${paymentCurrency.payment_method_id}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(paymentCurrency.payment_method_id);
|
|
95
|
+
if (supportAutoCharge === false) {
|
|
96
|
+
logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const customer = await Customer.findByPk(refund.customer_id);
|
|
101
|
+
if (!customer) {
|
|
102
|
+
logger.warn(`Customer not found: ${refund.customer_id}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// try refund transfer and reschedule on error
|
|
107
|
+
logger.info(`refund transfer attempt: ${refund.id}`);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
const client = paymentMethod.getOcapClient();
|
|
111
|
+
|
|
112
|
+
// check balance before transfer with transaction
|
|
113
|
+
result = await isBalanceSufficientForPayment({ paymentMethod, paymentCurrency, amount: refund.amount });
|
|
114
|
+
if (result.sufficient === false) {
|
|
115
|
+
logger.error('refund transfer aborted on preCheck', { id: refund.id, result });
|
|
116
|
+
throw new CustomError(result.reason, 'app balance not sufficient for this refund');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// do the transfer
|
|
120
|
+
const signed = await client.signTransferV2Tx({
|
|
121
|
+
tx: {
|
|
122
|
+
itx: {
|
|
123
|
+
to: customer.did,
|
|
124
|
+
value: '0',
|
|
125
|
+
assets: [],
|
|
126
|
+
tokens: [{ address: paymentCurrency.contract, value: refund.amount }],
|
|
127
|
+
data: {
|
|
128
|
+
typeUrl: 'json',
|
|
129
|
+
// @ts-ignore
|
|
130
|
+
value: {
|
|
131
|
+
appId: wallet.address,
|
|
132
|
+
reason: refund.reason,
|
|
133
|
+
refundId: refund.id,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
wallet,
|
|
139
|
+
});
|
|
140
|
+
// @ts-ignore
|
|
141
|
+
const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
|
|
142
|
+
// @ts-ignore
|
|
143
|
+
const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, getGasPayerExtra(buffer));
|
|
144
|
+
|
|
145
|
+
logger.info('refund transfer done', { id: refund.id, txHash });
|
|
146
|
+
await refund.update({
|
|
147
|
+
status: 'succeeded',
|
|
148
|
+
last_attempt_error: null,
|
|
149
|
+
payment_details: {
|
|
150
|
+
arcblock: {
|
|
151
|
+
tx_hash: txHash,
|
|
152
|
+
payer: wallet.address,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logger.error('refund transfer failed', { error: err, id: refund.id });
|
|
158
|
+
|
|
159
|
+
const error: PaymentError = {
|
|
160
|
+
type: 'card_error',
|
|
161
|
+
code: err.code,
|
|
162
|
+
message: err.message,
|
|
163
|
+
payment_method_id: paymentMethod.id,
|
|
164
|
+
payment_method_type: paymentMethod.type,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const updates = await handleRefundFailed(refund, error);
|
|
168
|
+
await refund.update(updates.refund);
|
|
169
|
+
|
|
170
|
+
// reschedule next attempt
|
|
171
|
+
const retryAt = updates.refund.next_attempt;
|
|
172
|
+
if (retryAt) {
|
|
173
|
+
refundQueue.push({
|
|
174
|
+
id: refund.id,
|
|
175
|
+
job: { refundId: refund.id, retryOnError: job.retryOnError },
|
|
176
|
+
runAt: retryAt,
|
|
177
|
+
});
|
|
178
|
+
logger.error('refund transfer retry scheduled', { id: refund.id, retryAt });
|
|
179
|
+
} else {
|
|
180
|
+
logger.info('refund job deleted since no retry', { id: refund.id });
|
|
181
|
+
refundQueue.delete(refund.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const refundQueue = createQueue<RefundJob>({
|
|
187
|
+
name: 'refund',
|
|
188
|
+
onJob: handleRefund,
|
|
189
|
+
options: {
|
|
190
|
+
concurrency: 1,
|
|
191
|
+
maxRetries: 0,
|
|
192
|
+
enableScheduledJob: true,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
export const startRefundQueue = async () => {
|
|
197
|
+
events.on('refund.created', (refund: Refund) => {
|
|
198
|
+
refundQueue.push({ id: refund.id, job: { refundId: refund.id } });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const refunds = await Refund.findAll({ where: { status: ['pending'] } });
|
|
202
|
+
refunds.forEach(async (x) => {
|
|
203
|
+
const exist = await refundQueue.get(x.id);
|
|
204
|
+
if (!exist) {
|
|
205
|
+
refundQueue.push({ id: x.id, job: { refundId: x.id } });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
refundQueue.on('failed', ({ id, job, error }) => {
|
|
211
|
+
logger.error('refund job failed', { id, job, error });
|
|
212
|
+
});
|
|
@@ -18,7 +18,7 @@ import { handleStripeSubscriptionSucceed } from '../integrations/stripe/handlers
|
|
|
18
18
|
import { ensureStripePaymentIntent, ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
19
19
|
import dayjs from '../libs/dayjs';
|
|
20
20
|
import logger from '../libs/logger';
|
|
21
|
-
import {
|
|
21
|
+
import { isCreditSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
|
|
22
22
|
import { authenticate } from '../libs/security';
|
|
23
23
|
import {
|
|
24
24
|
canUpsell,
|
|
@@ -714,7 +714,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
714
714
|
};
|
|
715
715
|
|
|
716
716
|
// if we can complete purchase with customer balance
|
|
717
|
-
const balance =
|
|
717
|
+
const balance = isCreditSufficientForPayment({
|
|
718
718
|
paymentMethod,
|
|
719
719
|
paymentCurrency,
|
|
720
720
|
customer,
|
package/api/src/routes/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import prices from './prices';
|
|
|
15
15
|
import pricingTables from './pricing-table';
|
|
16
16
|
import products from './products';
|
|
17
17
|
import redirect from './redirect';
|
|
18
|
+
import refunds from './refunds';
|
|
18
19
|
import settings from './settings';
|
|
19
20
|
import subscriptionItems from './subscription-items';
|
|
20
21
|
import subscriptions from './subscriptions';
|
|
@@ -57,6 +58,7 @@ router.use('/prices', prices);
|
|
|
57
58
|
router.use('/pricing-tables', pricingTables);
|
|
58
59
|
router.use('/products', products);
|
|
59
60
|
router.use('/redirect', redirect);
|
|
61
|
+
router.use('/refunds', refunds);
|
|
60
62
|
router.use('/settings', settings);
|
|
61
63
|
router.use('/subscription-items', subscriptionItems);
|
|
62
64
|
router.use('/subscriptions', subscriptions);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* eslint-disable consistent-return */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import type { WhereOptions } from 'sequelize';
|
|
5
|
+
|
|
6
|
+
import { authenticate } from '../libs/security';
|
|
7
|
+
import {
|
|
8
|
+
Customer,
|
|
9
|
+
Invoice,
|
|
10
|
+
PaymentCurrency,
|
|
11
|
+
PaymentIntent,
|
|
12
|
+
PaymentMethod,
|
|
13
|
+
Refund,
|
|
14
|
+
Subscription,
|
|
15
|
+
} from '../store/models';
|
|
16
|
+
|
|
17
|
+
const router = Router();
|
|
18
|
+
const auth = authenticate<Refund>({ component: true, roles: ['owner', 'admin'] });
|
|
19
|
+
|
|
20
|
+
const paginationSchema = Joi.object<{
|
|
21
|
+
page: number;
|
|
22
|
+
pageSize: number;
|
|
23
|
+
livemode?: boolean;
|
|
24
|
+
status?: string;
|
|
25
|
+
}>({
|
|
26
|
+
page: Joi.number().integer().min(1).default(1),
|
|
27
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
28
|
+
livemode: Joi.boolean().empty(''),
|
|
29
|
+
status: Joi.string().empty(''),
|
|
30
|
+
});
|
|
31
|
+
router.get('/', auth, async (req, res) => {
|
|
32
|
+
const { page, pageSize, livemode, status, ...query } = await paginationSchema.validateAsync(req.query, {
|
|
33
|
+
stripUnknown: false,
|
|
34
|
+
allowUnknown: true,
|
|
35
|
+
});
|
|
36
|
+
const where: WhereOptions<Refund> = {};
|
|
37
|
+
|
|
38
|
+
if (typeof livemode === 'boolean') {
|
|
39
|
+
where.livemode = livemode;
|
|
40
|
+
}
|
|
41
|
+
if (status) {
|
|
42
|
+
where.status = status
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((x) => x.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Object.keys(query)
|
|
49
|
+
.filter((x) => x.startsWith('metadata.'))
|
|
50
|
+
.forEach((key: string) => {
|
|
51
|
+
// @ts-ignore
|
|
52
|
+
where[key] = query[key];
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const { rows: list, count } = await Refund.findAndCountAll({
|
|
56
|
+
where,
|
|
57
|
+
order: [['created_at', 'DESC']],
|
|
58
|
+
offset: (page - 1) * pageSize,
|
|
59
|
+
limit: pageSize,
|
|
60
|
+
include: [
|
|
61
|
+
{ model: Customer, as: 'customer' },
|
|
62
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
63
|
+
// { model: PaymentIntent, as: 'paymentIntent' },
|
|
64
|
+
// { model: Invoice, as: 'invoice' },
|
|
65
|
+
// { model: Subscription, as: 'subscription' },
|
|
66
|
+
],
|
|
67
|
+
distinct: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
res.json({ count, list });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
router.get('/:id', auth, async (req, res) => {
|
|
74
|
+
const doc = await Refund.findByPk(req.params.id as string, {
|
|
75
|
+
include: [
|
|
76
|
+
{ model: Customer, as: 'customer' },
|
|
77
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
78
|
+
{ model: PaymentIntent, as: 'paymentIntent' },
|
|
79
|
+
{ model: Invoice, as: 'invoice' },
|
|
80
|
+
{ model: Subscription, as: 'subscription' },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (doc) {
|
|
85
|
+
// @ts-ignore
|
|
86
|
+
const paymentMethod = await PaymentMethod.findByPk(doc.paymentCurrency.payment_method_id);
|
|
87
|
+
return res.json({ ...doc.toJSON(), paymentMethod: paymentMethod?.toJSON() });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return res.status(404).json(null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export default router;
|