payment-kit 1.13.159 → 1.13.161
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/hooks/pre-flight.ts +0 -3
- package/api/src/index.ts +4 -2
- package/api/src/integrations/stripe/handlers/invoice.ts +6 -0
- package/api/src/integrations/stripe/handlers/setup-intent.ts +53 -6
- package/api/src/integrations/stripe/handlers/subscription.ts +5 -3
- package/api/src/integrations/stripe/resource.ts +12 -4
- package/api/src/libs/subscription.ts +12 -1
- package/api/src/routes/checkout-sessions.ts +0 -1
- package/api/src/routes/connect/change-payment.ts +106 -0
- package/api/src/routes/connect/{update.ts → change-plan.ts} +1 -1
- package/api/src/routes/connect/setup.ts +5 -3
- package/api/src/routes/connect/shared.ts +50 -11
- package/api/src/routes/invoices.ts +24 -0
- package/api/src/routes/payment-intents.ts +24 -0
- package/api/src/routes/refunds.ts +24 -0
- package/api/src/routes/subscriptions.ts +254 -6
- package/api/src/store/migrate.ts +1 -1
- package/api/src/store/models/setup-intent.ts +2 -5
- package/blocklet.yml +1 -1
- package/package.json +14 -14
- package/src/app.tsx +14 -4
- package/src/components/metadata/list.tsx +25 -0
- package/src/components/price/currency-select.tsx +1 -1
- package/src/components/subscription/portal/actions.tsx +4 -6
- package/src/libs/util.ts +7 -21
- package/src/pages/admin/billing/invoices/detail.tsx +2 -10
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -9
- package/src/pages/admin/customers/customers/detail.tsx +3 -3
- package/src/pages/admin/payments/intents/detail.tsx +2 -10
- package/src/pages/admin/payments/links/detail.tsx +6 -14
- package/src/pages/admin/payments/refunds/detail.tsx +2 -10
- package/src/pages/admin/products/prices/detail.tsx +6 -13
- package/src/pages/admin/products/pricing-tables/detail.tsx +6 -14
- package/src/pages/admin/products/products/detail.tsx +6 -14
- package/src/pages/customer/invoice/past-due.tsx +49 -15
- package/src/pages/customer/subscription/change-payment.tsx +362 -0
- package/src/pages/customer/subscription/{update.tsx → change-plan.tsx} +26 -37
- package/src/pages/customer/subscription/detail.tsx +19 -7
- package/api/src/libs/hooks.ts +0 -25
|
@@ -6,12 +6,13 @@ import pick from 'lodash/pick';
|
|
|
6
6
|
import uniq from 'lodash/uniq';
|
|
7
7
|
import type { WhereOptions } from 'sequelize';
|
|
8
8
|
|
|
9
|
+
import { ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
9
10
|
import { getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
|
|
10
11
|
import dayjs from '../libs/dayjs';
|
|
11
12
|
import logger from '../libs/logger';
|
|
12
13
|
import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
13
14
|
import { authenticate } from '../libs/security';
|
|
14
|
-
import { expandLineItems, isLineItemAligned } from '../libs/session';
|
|
15
|
+
import { expandLineItems, getFastCheckoutAmount, isLineItemAligned } from '../libs/session';
|
|
15
16
|
import { createProration, getSubscriptionCreateSetup, getSubscriptionRefundSetup } from '../libs/subscription';
|
|
16
17
|
import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
|
|
17
18
|
import { invoiceQueue } from '../queues/invoice';
|
|
@@ -27,6 +28,7 @@ import { Price } from '../store/models/price';
|
|
|
27
28
|
import { PricingTable } from '../store/models/pricing-table';
|
|
28
29
|
import { Product } from '../store/models/product';
|
|
29
30
|
import { Refund } from '../store/models/refund';
|
|
31
|
+
import { SetupIntent } from '../store/models/setup-intent';
|
|
30
32
|
import { Subscription, TSubscription } from '../store/models/subscription';
|
|
31
33
|
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
32
34
|
import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '../store/models/types';
|
|
@@ -379,7 +381,11 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
379
381
|
return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
|
|
380
382
|
}
|
|
381
383
|
|
|
382
|
-
|
|
384
|
+
if (doc.cancel_at_period_end) {
|
|
385
|
+
await updateStripeSubscription(doc, { cancel_at_period_end: false });
|
|
386
|
+
} else {
|
|
387
|
+
await updateStripeSubscription(doc, { cancel_at: null });
|
|
388
|
+
}
|
|
383
389
|
|
|
384
390
|
await doc.update({ cancel_at_period_end: false, cancel_at: 0, canceled_at: 0 });
|
|
385
391
|
|
|
@@ -873,7 +879,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
873
879
|
} else if (['NO_TOKEN', 'NO_ENOUGH_TOKEN'].includes(delegation.reason as string)) {
|
|
874
880
|
connectAction = 'collect';
|
|
875
881
|
} else {
|
|
876
|
-
connectAction = '
|
|
882
|
+
connectAction = 'change-plan';
|
|
877
883
|
}
|
|
878
884
|
}
|
|
879
885
|
}
|
|
@@ -944,7 +950,7 @@ const getUpdateTable = async (subscription: Subscription) => {
|
|
|
944
950
|
};
|
|
945
951
|
|
|
946
952
|
// Check that the subscription is upgradable
|
|
947
|
-
router.get('/:id/
|
|
953
|
+
router.get('/:id/change-plan', authPortal, async (req, res) => {
|
|
948
954
|
try {
|
|
949
955
|
const subscription = await Subscription.findByPk(req.params.id);
|
|
950
956
|
if (!subscription) {
|
|
@@ -970,8 +976,8 @@ router.get('/:id/update', authPortal, async (req, res) => {
|
|
|
970
976
|
}
|
|
971
977
|
});
|
|
972
978
|
|
|
973
|
-
// Simulate subscription
|
|
974
|
-
router.post('/:id/
|
|
979
|
+
// Simulate subscription plan change
|
|
980
|
+
router.post('/:id/change-plan', authPortal, async (req, res) => {
|
|
975
981
|
try {
|
|
976
982
|
const subscription = await Subscription.findByPk(req.params.id);
|
|
977
983
|
if (!subscription) {
|
|
@@ -1063,6 +1069,248 @@ router.get('/:id/proration', authPortal, async (req, res) => {
|
|
|
1063
1069
|
}
|
|
1064
1070
|
});
|
|
1065
1071
|
|
|
1072
|
+
// Check payment change status
|
|
1073
|
+
router.get('/:id/change-payment', authPortal, async (req, res) => {
|
|
1074
|
+
const subscription = await Subscription.findByPk(req.params.id);
|
|
1075
|
+
if (!subscription) {
|
|
1076
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
1077
|
+
}
|
|
1078
|
+
const context = subscription.metadata.changePayment || {};
|
|
1079
|
+
if (!context.setup_intent_id) {
|
|
1080
|
+
return res.status(404).json({ error: 'Subscription change payment context not found' });
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
|
|
1084
|
+
return res.json({ subscription, setupIntent });
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
// Prepare setupIntent for payment change
|
|
1088
|
+
router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
1089
|
+
try {
|
|
1090
|
+
const subscription = await Subscription.findByPk(req.params.id);
|
|
1091
|
+
if (!subscription) {
|
|
1092
|
+
return res.status(404).json({ error: `Subscription ${req.params.id} not found when change payment` });
|
|
1093
|
+
}
|
|
1094
|
+
if (subscription.isActive() === false) {
|
|
1095
|
+
return res.status(400).json({ error: `Subscription ${req.params.id} not active when change payment` });
|
|
1096
|
+
}
|
|
1097
|
+
const paymentCurrency = await PaymentCurrency.findByPk(req.body.payment_currency);
|
|
1098
|
+
if (!paymentCurrency) {
|
|
1099
|
+
return res
|
|
1100
|
+
.status(400)
|
|
1101
|
+
.json({ error: `Payment currency ${req.body.payment_currency} not found when change payment` });
|
|
1102
|
+
}
|
|
1103
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
1104
|
+
if (!paymentMethod) {
|
|
1105
|
+
return res
|
|
1106
|
+
.status(400)
|
|
1107
|
+
.json({ error: `Payment method ${paymentCurrency.payment_method_id} not found when change payment` });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
1111
|
+
if (paymentMethod.type === 'stripe') {
|
|
1112
|
+
await customer?.update({ address: Object.assign({}, customer.address, req.body.billing_address) });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (subscription.currency_id === paymentCurrency.id) {
|
|
1116
|
+
return res.status(400).json({ error: 'Payment currency not changed when change payment' });
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const previousPaymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
1120
|
+
if (previousPaymentMethod?.type === 'stripe') {
|
|
1121
|
+
if (!subscription.payment_details?.stripe?.subscription_id) {
|
|
1122
|
+
return res.status(400).json({ error: 'Can not change from stripe without stripe subscription id' });
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ensure setupIntent
|
|
1127
|
+
const context = subscription.metadata.changePayment || {};
|
|
1128
|
+
let setupIntent: SetupIntent | null = null;
|
|
1129
|
+
if (context.setup_intent_id) {
|
|
1130
|
+
// should be cleared after success
|
|
1131
|
+
setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
|
|
1132
|
+
}
|
|
1133
|
+
// Reuse existing setupIntent if not succeeded
|
|
1134
|
+
if (setupIntent && setupIntent.status !== 'succeeded') {
|
|
1135
|
+
await setupIntent.update({
|
|
1136
|
+
status: 'requires_capture',
|
|
1137
|
+
customer_id: subscription.customer_id,
|
|
1138
|
+
currency_id: paymentCurrency.id,
|
|
1139
|
+
payment_method_id: paymentMethod.id,
|
|
1140
|
+
last_setup_error: null,
|
|
1141
|
+
});
|
|
1142
|
+
logger.info('setupIntent reset on subscription payment change submit', {
|
|
1143
|
+
subscription: subscription.id,
|
|
1144
|
+
intent: setupIntent.id,
|
|
1145
|
+
});
|
|
1146
|
+
} else {
|
|
1147
|
+
setupIntent = await SetupIntent.create({
|
|
1148
|
+
livemode: !!subscription.livemode,
|
|
1149
|
+
customer_id: subscription.customer_id,
|
|
1150
|
+
description: subscription.description || `payment change setup for ${subscription.id}`,
|
|
1151
|
+
currency_id: paymentCurrency.id,
|
|
1152
|
+
payment_method_id: paymentMethod.id,
|
|
1153
|
+
status: 'requires_payment_method',
|
|
1154
|
+
payment_method_types: [paymentMethod.type],
|
|
1155
|
+
flow_directions: ['inbound', 'outbound'],
|
|
1156
|
+
usage: 'off_session',
|
|
1157
|
+
metadata: {
|
|
1158
|
+
subscription_id: subscription.id,
|
|
1159
|
+
from_currency: subscription.currency_id,
|
|
1160
|
+
to_currency: paymentCurrency.id,
|
|
1161
|
+
from_method: subscription.default_payment_method_id,
|
|
1162
|
+
to_method: paymentMethod.id,
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
// persist setup intent id
|
|
1167
|
+
await subscription.update({
|
|
1168
|
+
metadata: { ...subscription.metadata, changePayment: { setup_intent_id: setupIntent.id } },
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
logger.info('setupIntent created on subscription payment change submit', {
|
|
1172
|
+
subscription: subscription.id,
|
|
1173
|
+
intent: setupIntent.id,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// if we can complete purchase without any wallet interaction
|
|
1178
|
+
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
1179
|
+
const lineItems = await Price.expand(
|
|
1180
|
+
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity }))
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
let stripeContext: any = null;
|
|
1184
|
+
let delegation: any = null;
|
|
1185
|
+
if (paymentMethod.type === 'stripe') {
|
|
1186
|
+
const client = paymentMethod.getStripeClient();
|
|
1187
|
+
let exist;
|
|
1188
|
+
if (subscription.payment_details?.stripe?.subscription_id) {
|
|
1189
|
+
exist = await client.subscriptions.retrieve(subscription.payment_details.stripe.subscription_id);
|
|
1190
|
+
}
|
|
1191
|
+
if (exist) {
|
|
1192
|
+
if (exist.status === 'paused') {
|
|
1193
|
+
await client.subscriptions.resume(exist.id, {
|
|
1194
|
+
proration_behavior: 'none',
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
const result = await client.subscriptions.update(exist.id, {
|
|
1198
|
+
trial_end: subscription.current_period_end,
|
|
1199
|
+
proration_behavior: 'none',
|
|
1200
|
+
pause_collection: '',
|
|
1201
|
+
});
|
|
1202
|
+
logger.info('stripe subscription updated on subscription payment change', {
|
|
1203
|
+
subscription: subscription.id,
|
|
1204
|
+
intent: setupIntent.id,
|
|
1205
|
+
stripeSubscriptionId: exist.id,
|
|
1206
|
+
result,
|
|
1207
|
+
});
|
|
1208
|
+
await setupIntent.update({
|
|
1209
|
+
status: 'succeeded',
|
|
1210
|
+
last_setup_error: null,
|
|
1211
|
+
payment_method_types: [paymentMethod.type],
|
|
1212
|
+
});
|
|
1213
|
+
await subscription.update({
|
|
1214
|
+
currency_id: paymentCurrency.id,
|
|
1215
|
+
default_payment_method_id: paymentMethod.id,
|
|
1216
|
+
payment_settings: {
|
|
1217
|
+
payment_method_types: [paymentMethod.type],
|
|
1218
|
+
payment_method_options: {},
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
} else {
|
|
1222
|
+
const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
|
|
1223
|
+
|
|
1224
|
+
// changing from crypto to stripe: create/resume stripe subscription, pause crypto subscription
|
|
1225
|
+
const stripeSubscription = await ensureStripeSubscription(
|
|
1226
|
+
subscription,
|
|
1227
|
+
paymentMethod,
|
|
1228
|
+
paymentCurrency,
|
|
1229
|
+
lineItems,
|
|
1230
|
+
0,
|
|
1231
|
+
subscription.current_period_end
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
stripeContext = {
|
|
1235
|
+
type: 'subscription',
|
|
1236
|
+
id: stripeSubscription.id,
|
|
1237
|
+
// @ts-ignore
|
|
1238
|
+
client_secret:
|
|
1239
|
+
stripeSubscription.latest_invoice?.payment_intent?.client_secret ||
|
|
1240
|
+
stripeSubscription.pending_setup_intent?.client_secret,
|
|
1241
|
+
intent_type: stripeSubscription.latest_invoice?.payment_intent ? 'payment_intent' : 'setup_intent',
|
|
1242
|
+
publishable_key: settings.stripe?.publishable_key,
|
|
1243
|
+
status: stripeSubscription.status,
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
await setupIntent.update({
|
|
1247
|
+
setup_details: {
|
|
1248
|
+
stripe: {
|
|
1249
|
+
customer_id: stripeSubscription.customer,
|
|
1250
|
+
subscription_id: stripeSubscription.id,
|
|
1251
|
+
setup_intent_id: stripeSubscription.pending_setup_intent.id,
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
// changing from stripe to crypto: pause stripe subscription
|
|
1258
|
+
if (previousPaymentMethod!.type === 'stripe') {
|
|
1259
|
+
const client = await previousPaymentMethod?.getStripeClient();
|
|
1260
|
+
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id as string;
|
|
1261
|
+
const result = await client?.subscriptions.update(stripeSubscriptionId, {
|
|
1262
|
+
pause_collection: {
|
|
1263
|
+
behavior: 'void',
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
logger.info('stripe subscription paused on payment change', {
|
|
1267
|
+
subscription: subscription.id,
|
|
1268
|
+
stripeSubscription: stripeSubscriptionId,
|
|
1269
|
+
result,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// changing from crypto to crypto: just update the subscription
|
|
1274
|
+
delegation = await isDelegationSufficientForPayment({
|
|
1275
|
+
paymentMethod,
|
|
1276
|
+
paymentCurrency,
|
|
1277
|
+
userDid: customer!.did,
|
|
1278
|
+
amount: getFastCheckoutAmount(lineItems, 'subscription', paymentCurrency.id, false),
|
|
1279
|
+
});
|
|
1280
|
+
if (delegation.sufficient) {
|
|
1281
|
+
await setupIntent.update({
|
|
1282
|
+
status: 'succeeded',
|
|
1283
|
+
last_setup_error: null,
|
|
1284
|
+
payment_method_types: [paymentMethod.type],
|
|
1285
|
+
});
|
|
1286
|
+
await subscription.update({
|
|
1287
|
+
currency_id: paymentCurrency.id,
|
|
1288
|
+
default_payment_method_id: paymentMethod.id,
|
|
1289
|
+
payment_settings: {
|
|
1290
|
+
payment_method_types: [paymentMethod.type],
|
|
1291
|
+
payment_method_options: {},
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
logger.info('Subscription payment change done on delegation enough', {
|
|
1295
|
+
subscription: subscription.id,
|
|
1296
|
+
intent: setupIntent.id,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return res.json({
|
|
1302
|
+
setupIntent,
|
|
1303
|
+
stripeContext,
|
|
1304
|
+
subscription,
|
|
1305
|
+
customer,
|
|
1306
|
+
delegation,
|
|
1307
|
+
});
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
console.error(err);
|
|
1310
|
+
return res.status(500).json({ code: err.code, error: err.message });
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1066
1314
|
// FIXME: this should be removed in future
|
|
1067
1315
|
// Clean up subscriptions that have invalid invoices and payments
|
|
1068
1316
|
router.delete('/cleanup', auth, async (req, res) => {
|
package/api/src/store/migrate.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { sequelize } from './sequelize';
|
|
|
8
8
|
|
|
9
9
|
const umzug = new Umzug({
|
|
10
10
|
migrations: {
|
|
11
|
-
glob: ['migrations/*.{ts,js}', { cwd: __dirname }],
|
|
11
|
+
glob: ['**/migrations/*.{ts,js}', { cwd: __dirname }],
|
|
12
12
|
// @FIXME: @wangshijun jianchao这边的注释了才能 blocklet dev 成功
|
|
13
13
|
// resolve: ({ name, path, context }) => {
|
|
14
14
|
// // eslint-disable-next-line import/no-dynamic-require, global-require
|
|
@@ -5,7 +5,7 @@ import type { LiteralUnion } from 'type-fest';
|
|
|
5
5
|
|
|
6
6
|
import { createEvent, createStatusEvent } from '../../libs/audit';
|
|
7
7
|
import { createIdGenerator } from '../../libs/util';
|
|
8
|
-
import type { PaymentError, PaymentMethodOptions } from './types';
|
|
8
|
+
import type { PaymentDetails, PaymentError, PaymentMethodOptions } from './types';
|
|
9
9
|
|
|
10
10
|
const nextId = createIdGenerator('seti', 24);
|
|
11
11
|
|
|
@@ -49,10 +49,7 @@ export class SetupIntent extends Model<InferAttributes<SetupIntent>, InferCreati
|
|
|
49
49
|
declare payment_method_id: string;
|
|
50
50
|
|
|
51
51
|
// 3rd party payment tx hash
|
|
52
|
-
declare setup_details?:
|
|
53
|
-
tx_hash?: string;
|
|
54
|
-
payer?: string;
|
|
55
|
-
};
|
|
52
|
+
declare setup_details?: PaymentDetails;
|
|
56
53
|
|
|
57
54
|
// TODO: following fields not supported yet
|
|
58
55
|
// application
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.161",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "cross-env COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"clean": "node scripts/build-clean.js",
|
|
14
14
|
"bundle": "tsc --noEmit && npm run bundle:client && npm run bundle:api",
|
|
15
15
|
"bundle:client": "vite build",
|
|
16
|
-
"bundle:api": "npm run clean && tsc -p tsconfig.api.json && blocklet bundle --
|
|
16
|
+
"bundle:api": "npm run clean && tsc -p tsconfig.api.json && blocklet bundle --compact --external sqlite3 --create-release",
|
|
17
17
|
"build": "npm run clean && tsc -p tsconfig.api.json && npm run bundle:client",
|
|
18
18
|
"types": "rm -rf types && tsc -p tsconfig.types.json && rm -f ../../packages/types/lib/*.d.ts && cp -f types/store/models/*.d.ts ../../packages/types/lib",
|
|
19
19
|
"deploy": "npm run bundle && blocklet deploy .blocklet/bundle",
|
|
@@ -45,20 +45,20 @@
|
|
|
45
45
|
"@abtnode/cron": "1.16.23",
|
|
46
46
|
"@arcblock/did": "^1.18.110",
|
|
47
47
|
"@arcblock/did-auth-storage-nedb": "^1.7.1",
|
|
48
|
-
"@arcblock/did-connect": "^2.9.
|
|
48
|
+
"@arcblock/did-connect": "^2.9.39",
|
|
49
49
|
"@arcblock/did-util": "^1.18.110",
|
|
50
50
|
"@arcblock/jwt": "^1.18.110",
|
|
51
|
-
"@arcblock/ux": "^2.9.
|
|
51
|
+
"@arcblock/ux": "^2.9.39",
|
|
52
52
|
"@blocklet/logger": "1.16.23",
|
|
53
|
-
"@blocklet/payment-react": "1.13.
|
|
53
|
+
"@blocklet/payment-react": "1.13.161",
|
|
54
54
|
"@blocklet/sdk": "1.16.23",
|
|
55
|
-
"@blocklet/ui-react": "^2.9.
|
|
56
|
-
"@blocklet/uploader": "^0.0.
|
|
57
|
-
"@mui/icons-material": "^5.15.
|
|
58
|
-
"@mui/lab": "^5.0.0-alpha.
|
|
59
|
-
"@mui/material": "^5.15.
|
|
60
|
-
"@mui/styles": "^5.15.
|
|
61
|
-
"@mui/system": "^5.15.
|
|
55
|
+
"@blocklet/ui-react": "^2.9.39",
|
|
56
|
+
"@blocklet/uploader": "^0.0.74",
|
|
57
|
+
"@mui/icons-material": "^5.15.11",
|
|
58
|
+
"@mui/lab": "^5.0.0-alpha.166",
|
|
59
|
+
"@mui/material": "^5.15.11",
|
|
60
|
+
"@mui/styles": "^5.15.11",
|
|
61
|
+
"@mui/system": "^5.15.11",
|
|
62
62
|
"@ocap/asset": "^1.18.110",
|
|
63
63
|
"@ocap/client": "^1.18.110",
|
|
64
64
|
"@ocap/mcrypto": "^1.18.110",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"devDependencies": {
|
|
111
111
|
"@abtnode/types": "1.16.23",
|
|
112
112
|
"@arcblock/eslint-config-ts": "^0.2.4",
|
|
113
|
-
"@blocklet/payment-types": "1.13.
|
|
113
|
+
"@blocklet/payment-types": "1.13.161",
|
|
114
114
|
"@types/cookie-parser": "^1.4.6",
|
|
115
115
|
"@types/cors": "^2.8.17",
|
|
116
116
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -149,5 +149,5 @@
|
|
|
149
149
|
"parser": "typescript"
|
|
150
150
|
}
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "55cb16d9cd06b91b949a1a1c88b14670b0c1aca8"
|
|
153
153
|
}
|
package/src/app.tsx
CHANGED
|
@@ -23,7 +23,8 @@ const CustomerHome = React.lazy(() => import('./pages/customer/index'));
|
|
|
23
23
|
const CustomerInvoiceDetail = React.lazy(() => import('./pages/customer/invoice/detail'));
|
|
24
24
|
const CustomerInvoicePastDue = React.lazy(() => import('./pages/customer/invoice/past-due'));
|
|
25
25
|
const CustomerSubscriptionDetail = React.lazy(() => import('./pages/customer/subscription/detail'));
|
|
26
|
-
const
|
|
26
|
+
const CustomerSubscriptionChangePlan = React.lazy(() => import('./pages/customer/subscription/change-plan'));
|
|
27
|
+
const CustomerSubscriptionChangePayment = React.lazy(() => import('./pages/customer/subscription/change-payment'));
|
|
27
28
|
|
|
28
29
|
const theme = createTheme({
|
|
29
30
|
typography: {
|
|
@@ -71,11 +72,20 @@ function App() {
|
|
|
71
72
|
}
|
|
72
73
|
/>
|
|
73
74
|
<Route
|
|
74
|
-
key="customer-subscription"
|
|
75
|
-
path="/customer/subscription/:id/
|
|
75
|
+
key="customer-subscription-change-plan"
|
|
76
|
+
path="/customer/subscription/:id/change-plan"
|
|
77
|
+
element={
|
|
78
|
+
<Layout>
|
|
79
|
+
<CustomerSubscriptionChangePlan />
|
|
80
|
+
</Layout>
|
|
81
|
+
}
|
|
82
|
+
/>
|
|
83
|
+
<Route
|
|
84
|
+
key="customer-subscription-change-payment"
|
|
85
|
+
path="/customer/subscription/:id/change-payment"
|
|
76
86
|
element={
|
|
77
87
|
<Layout>
|
|
78
|
-
<
|
|
88
|
+
<CustomerSubscriptionChangePayment />
|
|
79
89
|
</Layout>
|
|
80
90
|
}
|
|
81
91
|
/>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import { Typography } from '@mui/material';
|
|
3
|
+
import isEmpty from 'lodash/isEmpty';
|
|
4
|
+
import isObject from 'lodash/isObject';
|
|
5
|
+
|
|
6
|
+
import InfoRow from '../info-row';
|
|
7
|
+
|
|
8
|
+
export default function MetadataList({ data }: { data: any }) {
|
|
9
|
+
const { t } = useLocaleContext();
|
|
10
|
+
|
|
11
|
+
if (isEmpty(data)) {
|
|
12
|
+
return <Typography color="text.secondary">{t('common.metadata.empty')}</Typography>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// skip non-string values
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{Object.keys(data || {})
|
|
19
|
+
.filter((key) => isObject(data[key]) === false)
|
|
20
|
+
.map((key) => (
|
|
21
|
+
<InfoRow key={key} label={key} value={data[key]} />
|
|
22
|
+
))}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -27,7 +27,7 @@ export default function CurrencySelect({ mode: initialMode, hasSelected, onSelec
|
|
|
27
27
|
if (mode === 'selecting') {
|
|
28
28
|
return (
|
|
29
29
|
<Select value="" sx={{ width: 260 }} size="small" onChange={handleSelect}>
|
|
30
|
-
{getSupportedPaymentMethods(settings.paymentMethods, hasSelected).map((method) => [
|
|
30
|
+
{getSupportedPaymentMethods(settings.paymentMethods, (x) => !hasSelected(x)).map((method) => [
|
|
31
31
|
<ListSubheader key={method.id} sx={{ fontSize: '1rem', color: 'text.secondary', lineHeight: '2.5rem' }}>
|
|
32
32
|
{method.name}
|
|
33
33
|
</ListSubheader>,
|
|
@@ -25,7 +25,7 @@ const fetchUpdateOptions = ({ id, showUpdate }: { id: string; showUpdate: boolea
|
|
|
25
25
|
return Promise.resolve(false);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
return api.get(`/api/subscriptions/${id}/
|
|
28
|
+
return api.get(`/api/subscriptions/${id}/change-plan`).then((res) => !!res.data);
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export function SubscriptionActionsInner({ subscription, showUpdate, onChange }: Props) {
|
|
@@ -82,9 +82,7 @@ export function SubscriptionActionsInner({ subscription, showUpdate, onChange }:
|
|
|
82
82
|
size="small"
|
|
83
83
|
onClick={() => {
|
|
84
84
|
if (action.action === 'pastDue') {
|
|
85
|
-
navigate(
|
|
86
|
-
`/customer/invoice/${subscription.latest_invoice_id}?action=${action.canRenew ? 'renew' : 'pay'}`
|
|
87
|
-
);
|
|
85
|
+
navigate(`/customer/invoice/past-due?subscription=${subscription.id}`);
|
|
88
86
|
} else {
|
|
89
87
|
setState({
|
|
90
88
|
action: action.action,
|
|
@@ -101,9 +99,9 @@ export function SubscriptionActionsInner({ subscription, showUpdate, onChange }:
|
|
|
101
99
|
color="primary"
|
|
102
100
|
size="small"
|
|
103
101
|
onClick={() => {
|
|
104
|
-
navigate(`/customer/subscription/${subscription.id}/
|
|
102
|
+
navigate(`/customer/subscription/${subscription.id}/change-plan`);
|
|
105
103
|
}}>
|
|
106
|
-
{t('payment.customer.
|
|
104
|
+
{t('payment.customer.changePlan.button')}
|
|
107
105
|
</Button>
|
|
108
106
|
)}
|
|
109
107
|
{subscription.service_actions?.map((x) => (
|
package/src/libs/util.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-nested-ternary */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
|
-
import {
|
|
4
|
-
formatCheckoutHeadlines,
|
|
5
|
-
formatPrice,
|
|
6
|
-
getCheckoutAmount,
|
|
7
|
-
getPriceCurrencyOptions,
|
|
8
|
-
} from '@blocklet/payment-react';
|
|
3
|
+
import { formatCheckoutHeadlines, formatPrice, getPriceCurrencyOptions } from '@blocklet/payment-react';
|
|
9
4
|
import type {
|
|
10
5
|
LineItem,
|
|
11
6
|
TLineItemExpanded,
|
|
@@ -14,12 +9,12 @@ import type {
|
|
|
14
9
|
TPaymentMethodExpanded,
|
|
15
10
|
TPrice,
|
|
16
11
|
TProductExpanded,
|
|
12
|
+
TSubscriptionExpanded,
|
|
17
13
|
} from '@blocklet/payment-types';
|
|
18
14
|
import cloneDeep from 'lodash/cloneDeep';
|
|
19
15
|
import isEqual from 'lodash/isEqual';
|
|
20
16
|
|
|
21
17
|
import { t } from '../locales/index';
|
|
22
|
-
import dayjs from './dayjs';
|
|
23
18
|
|
|
24
19
|
export const formatProductPrice = (
|
|
25
20
|
{ prices, unit_label }: { prices: TPrice[]; unit_label: string },
|
|
@@ -63,20 +58,7 @@ export function getPriceFromProducts(products: TProductExpanded[], priceId: stri
|
|
|
63
58
|
}
|
|
64
59
|
|
|
65
60
|
export function formatPaymentLinkPricing(link: TPaymentLinkExpanded, currency: TPaymentCurrency) {
|
|
66
|
-
|
|
67
|
-
return formatCheckoutHeadlines(
|
|
68
|
-
{
|
|
69
|
-
mode: 'payment',
|
|
70
|
-
status: 'open',
|
|
71
|
-
payment_status: 'unpaid',
|
|
72
|
-
currency,
|
|
73
|
-
amount_total: amount.total,
|
|
74
|
-
amount_subtotal: amount.subtotal,
|
|
75
|
-
expires_at: dayjs().add(30, 'days').unix(),
|
|
76
|
-
...link,
|
|
77
|
-
} as any,
|
|
78
|
-
currency
|
|
79
|
-
);
|
|
61
|
+
return formatCheckoutHeadlines(link.line_items, currency, link.subscription_data?.trial_period_days || 0);
|
|
80
62
|
}
|
|
81
63
|
|
|
82
64
|
export function getWebhookStatusColor(status: string) {
|
|
@@ -203,3 +185,7 @@ export const debounce = (fun: Function, wait: number) => {
|
|
|
203
185
|
}, wait);
|
|
204
186
|
};
|
|
205
187
|
};
|
|
188
|
+
|
|
189
|
+
export function canChangePaymentMethod(subscription: TSubscriptionExpanded) {
|
|
190
|
+
return subscription.items.every((x) => getPriceCurrencyOptions(x.price).length > 1);
|
|
191
|
+
}
|
|
@@ -8,7 +8,6 @@ import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/ma
|
|
|
8
8
|
import { styled } from '@mui/system';
|
|
9
9
|
import { fromUnitToToken } from '@ocap/util';
|
|
10
10
|
import { useRequest, useSetState } from 'ahooks';
|
|
11
|
-
import { isEmpty } from 'lodash';
|
|
12
11
|
import { Link } from 'react-router-dom';
|
|
13
12
|
|
|
14
13
|
import Copyable from '../../../../components/copyable';
|
|
@@ -19,6 +18,7 @@ import InfoRow from '../../../../components/info-row';
|
|
|
19
18
|
import InvoiceActions from '../../../../components/invoice/action';
|
|
20
19
|
import InvoiceTable from '../../../../components/invoice/table';
|
|
21
20
|
import MetadataEditor from '../../../../components/metadata/editor';
|
|
21
|
+
import MetadataList from '../../../../components/metadata/list';
|
|
22
22
|
import PaymentList from '../../../../components/payment-intent/list';
|
|
23
23
|
import RefundList from '../../../../components/refund/list';
|
|
24
24
|
import SectionHeader from '../../../../components/section/header';
|
|
@@ -186,15 +186,7 @@ export default function InvoiceDetail(props: { id: string }) {
|
|
|
186
186
|
</Button>
|
|
187
187
|
</SectionHeader>
|
|
188
188
|
<Box className="section-body">
|
|
189
|
-
{!state.editing.metadata &&
|
|
190
|
-
(isEmpty(data.metadata) ? (
|
|
191
|
-
<Typography color="text.secondary">{t('common.metadata.empty')}</Typography>
|
|
192
|
-
) : (
|
|
193
|
-
Object.keys(data.metadata || {}).map((key) => (
|
|
194
|
-
// @ts-ignore
|
|
195
|
-
<InfoRow key={key} label={key} value={data.metadata[key]} />
|
|
196
|
-
))
|
|
197
|
-
))}
|
|
189
|
+
{!state.editing.metadata && <MetadataList data={data.metadata} />}
|
|
198
190
|
{state.editing.metadata && (
|
|
199
191
|
<MetadataEditor
|
|
200
192
|
data={data}
|
|
@@ -7,7 +7,6 @@ import { ArrowBackOutlined, Edit } from '@mui/icons-material';
|
|
|
7
7
|
import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
|
8
8
|
import { styled } from '@mui/system';
|
|
9
9
|
import { useRequest, useSetState } from 'ahooks';
|
|
10
|
-
import { isEmpty } from 'lodash';
|
|
11
10
|
import { Link } from 'react-router-dom';
|
|
12
11
|
|
|
13
12
|
import Copyable from '../../../../components/copyable';
|
|
@@ -17,6 +16,7 @@ import EventList from '../../../../components/event/list';
|
|
|
17
16
|
import InfoRow from '../../../../components/info-row';
|
|
18
17
|
import InvoiceList from '../../../../components/invoice/list';
|
|
19
18
|
import MetadataEditor from '../../../../components/metadata/editor';
|
|
19
|
+
import MetadataList from '../../../../components/metadata/list';
|
|
20
20
|
import RefundList from '../../../../components/refund/list';
|
|
21
21
|
import SectionHeader from '../../../../components/section/header';
|
|
22
22
|
import SubscriptionActions from '../../../../components/subscription/actions';
|
|
@@ -174,14 +174,7 @@ export default function SubscriptionDetail(props: { id: string }) {
|
|
|
174
174
|
</Button>
|
|
175
175
|
</SectionHeader>
|
|
176
176
|
<Box className="section-body">
|
|
177
|
-
{!state.editing.metadata &&
|
|
178
|
-
(isEmpty(data.metadata) ? (
|
|
179
|
-
<Typography color="text.secondary">{t('common.metadata.empty')}</Typography>
|
|
180
|
-
) : (
|
|
181
|
-
Object.keys(data.metadata || {}).map((key) => (
|
|
182
|
-
<InfoRow key={key} label={key} value={data.metadata[key]} />
|
|
183
|
-
))
|
|
184
|
-
))}
|
|
177
|
+
{!state.editing.metadata && <MetadataList data={data.metadata} />}
|
|
185
178
|
{state.editing.metadata && (
|
|
186
179
|
<MetadataEditor
|
|
187
180
|
data={data}
|
|
@@ -29,14 +29,14 @@ import SubscriptionList from '../../../../components/subscription/list';
|
|
|
29
29
|
const fetchData = async (
|
|
30
30
|
id: string
|
|
31
31
|
): Promise<{ customer: TCustomerExpanded; summary: { [key: string]: GroupedBN } }> => {
|
|
32
|
-
const
|
|
32
|
+
const [customer, summary] = await Promise.all([
|
|
33
33
|
api.get(`/api/customers/${id}`).then((res) => res.data),
|
|
34
34
|
api.get(`/api/customers/${id}/summary`).then((res) => res.data),
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
37
|
return {
|
|
38
|
-
customer
|
|
39
|
-
summary
|
|
38
|
+
customer,
|
|
39
|
+
summary,
|
|
40
40
|
};
|
|
41
41
|
};
|
|
42
42
|
|