payment-kit 1.18.38 → 1.18.39
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/integrations/stripe/handlers/invoice.ts +22 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +9 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +137 -1
- package/api/src/queues/payment.ts +4 -0
- package/api/src/routes/subscriptions.ts +24 -17
- package/blocklet.yml +1 -1
- package/package.json +19 -19
- package/src/components/subscription/portal/actions.tsx +134 -24
- package/src/components/subscription/portal/list.tsx +2 -1
- package/src/pages/admin/payments/intents/detail.tsx +4 -1
- package/src/pages/customer/subscription/detail.tsx +4 -1
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
TEventExpanded,
|
|
20
20
|
TInvoiceItem,
|
|
21
21
|
} from '../../../store/models';
|
|
22
|
+
import { handleSubscriptionOnPaymentFailure } from './subscription';
|
|
22
23
|
|
|
23
24
|
export async function handleStripeInvoicePaid(invoice: Invoice, event: TEventExpanded) {
|
|
24
25
|
logger.info('invoice paid on stripe event', { locale: invoice.id });
|
|
@@ -91,6 +92,16 @@ export async function syncStripeInvoice(invoice: Invoice) {
|
|
|
91
92
|
)
|
|
92
93
|
);
|
|
93
94
|
logger.info('stripe invoice synced', { locale: invoice.id, remote: stripeInvoice.id });
|
|
95
|
+
const failedStatuses = ['uncollectible', 'finalization_failed', 'payment_failed'];
|
|
96
|
+
if (failedStatuses.includes(stripeInvoice.status || '')) {
|
|
97
|
+
logger.info('Invoice failed', { invoiceId: invoice.id });
|
|
98
|
+
if (invoice.subscription_id) {
|
|
99
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
100
|
+
if (subscription) {
|
|
101
|
+
handleSubscriptionOnPaymentFailure(subscription, 'syncStripeInvoice', client);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
107
|
|
|
@@ -376,4 +387,15 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
376
387
|
await invoice.update({ status: 'payment_failed' });
|
|
377
388
|
logger.info('invoice payment failed on stripe event', { locale: invoice.id });
|
|
378
389
|
}
|
|
390
|
+
|
|
391
|
+
const failedEvents = ['invoice.marked_uncollectible', 'invoice.finalization_failed', 'invoice.payment_failed'];
|
|
392
|
+
if (failedEvents.includes(event.type)) {
|
|
393
|
+
logger.info('Invoice failed', { invoiceId: invoice.id });
|
|
394
|
+
if (invoice.subscription_id) {
|
|
395
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
396
|
+
if (subscription) {
|
|
397
|
+
handleSubscriptionOnPaymentFailure(subscription, event.type, client);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
379
401
|
}
|
|
@@ -7,9 +7,10 @@ import type Stripe from 'stripe';
|
|
|
7
7
|
import dayjs from '../../../libs/dayjs';
|
|
8
8
|
import logger from '../../../libs/logger';
|
|
9
9
|
import { handlePaymentSucceed } from '../../../queues/payment';
|
|
10
|
-
import { Invoice, PaymentIntent, PaymentMethod, TEventExpanded } from '../../../store/models';
|
|
10
|
+
import { Invoice, PaymentIntent, PaymentMethod, Subscription, TEventExpanded } from '../../../store/models';
|
|
11
11
|
import { handleStripeInvoiceCreated } from './invoice';
|
|
12
12
|
import { events } from '../../../libs/event';
|
|
13
|
+
import { handleSubscriptionOnPaymentFailure } from './subscription';
|
|
13
14
|
|
|
14
15
|
export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, event?: TEventExpanded) {
|
|
15
16
|
const triggerRenew = paymentIntent.status !== 'succeeded';
|
|
@@ -184,6 +185,13 @@ export async function handlePaymentIntentEvent(event: TEventExpanded, client: St
|
|
|
184
185
|
attempted: true,
|
|
185
186
|
status_transitions: { ...invoice.status_transitions, marked_uncollectible_at: dayjs().unix() },
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
if (invoice.subscription_id) {
|
|
190
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
191
|
+
if (subscription) {
|
|
192
|
+
handleSubscriptionOnPaymentFailure(subscription, event.type, client);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
187
195
|
}
|
|
188
196
|
}
|
|
189
197
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import pick from 'lodash/pick';
|
|
2
2
|
import type Stripe from 'stripe';
|
|
3
3
|
|
|
4
|
+
import dayjs from '../../../libs/dayjs';
|
|
4
5
|
import logger from '../../../libs/logger';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
finalizeStripeSubscriptionUpdate,
|
|
8
|
+
getDaysUntilCancel,
|
|
9
|
+
getDaysUntilDue,
|
|
10
|
+
getDueUnit,
|
|
11
|
+
shouldCancelSubscription,
|
|
12
|
+
} from '../../../libs/subscription';
|
|
6
13
|
import { CheckoutSession, PaymentMethod, Subscription, TEventExpanded } from '../../../store/models';
|
|
7
14
|
import { getCheckoutSessionSubscriptionIds } from '../../../libs/session';
|
|
8
15
|
import { createEvent } from '../../../libs/audit';
|
|
@@ -139,3 +146,132 @@ export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe)
|
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
}
|
|
149
|
+
|
|
150
|
+
export async function syncStripeSubscriptionAfterRecovery(subscription: Subscription, paymentIntentId: string) {
|
|
151
|
+
if (!subscription.payment_details?.stripe?.subscription_id) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
157
|
+
if (method && method.type === 'stripe') {
|
|
158
|
+
const client = await method.getStripeClient();
|
|
159
|
+
await client.subscriptions.update(subscription.payment_details.stripe.subscription_id, {
|
|
160
|
+
cancel_at_period_end: false,
|
|
161
|
+
cancel_at: null,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
logger.info('Removed cancellation settings in Stripe after payment success', {
|
|
165
|
+
subscription: subscription.id,
|
|
166
|
+
stripeSubscription: subscription.payment_details.stripe.subscription_id,
|
|
167
|
+
paymentIntent: paymentIntentId,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.error('Failed to update Stripe subscription after payment success', {
|
|
172
|
+
error: err,
|
|
173
|
+
subscription: subscription.id,
|
|
174
|
+
paymentIntent: paymentIntentId,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function handleSubscriptionOnPaymentFailure(
|
|
180
|
+
subscription: Subscription,
|
|
181
|
+
eventType: string,
|
|
182
|
+
client: Stripe
|
|
183
|
+
) {
|
|
184
|
+
if (!subscription || !subscription.isActive()) {
|
|
185
|
+
logger.warn('Subscription is not active or not found', { subscription: subscription.id });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const now = dayjs().unix();
|
|
190
|
+
const { interval } = subscription.pending_invoice_item_interval;
|
|
191
|
+
const dueUnit = getDueUnit(interval);
|
|
192
|
+
const cancelUpdates: { [key: string]: any } = {};
|
|
193
|
+
const daysUntilCancel = getDaysUntilCancel(subscription);
|
|
194
|
+
const cancelSubscription = shouldCancelSubscription(subscription);
|
|
195
|
+
|
|
196
|
+
if (daysUntilCancel > 0) {
|
|
197
|
+
cancelUpdates.cancel_at = subscription.current_period_start + daysUntilCancel * dueUnit;
|
|
198
|
+
} else {
|
|
199
|
+
cancelUpdates.cancel_at_period_end = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (cancelSubscription) {
|
|
203
|
+
await subscription.update({
|
|
204
|
+
status: 'canceled',
|
|
205
|
+
canceled_at: now,
|
|
206
|
+
cancelation_details: {
|
|
207
|
+
comment: 'exceed_current_period',
|
|
208
|
+
feedback: 'other',
|
|
209
|
+
reason: 'payment_failed',
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
logger.warn(`[${eventType}] Subscription moved to canceled after payment failed`, {
|
|
213
|
+
subscription: subscription.id,
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
// check grace period
|
|
217
|
+
const daysUntilDue = getDaysUntilDue(subscription);
|
|
218
|
+
if (typeof daysUntilDue === 'number') {
|
|
219
|
+
const gracePeriodStart = subscription.current_period_start;
|
|
220
|
+
const graceDuration = daysUntilDue ? daysUntilDue * dueUnit : 0;
|
|
221
|
+
|
|
222
|
+
if (gracePeriodStart + graceDuration <= now) {
|
|
223
|
+
// past grace period, set to past_due
|
|
224
|
+
await subscription.update({
|
|
225
|
+
status: 'past_due',
|
|
226
|
+
...cancelUpdates,
|
|
227
|
+
cancelation_details: {
|
|
228
|
+
comment: 'past_due',
|
|
229
|
+
feedback: 'other',
|
|
230
|
+
reason: 'payment_failed',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
logger.warn(`[${eventType}] Subscription moved to past_due after payment failed`, {
|
|
234
|
+
subscription: subscription.id,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// sync to stripe
|
|
241
|
+
if (subscription.payment_details?.stripe?.subscription_id && client) {
|
|
242
|
+
try {
|
|
243
|
+
const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
244
|
+
if (method && method.type === 'stripe') {
|
|
245
|
+
const stripeUpdates: any = {
|
|
246
|
+
cancellation_details: {
|
|
247
|
+
comment: 'past_due',
|
|
248
|
+
feedback: 'other',
|
|
249
|
+
reason: 'payment_failed',
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (cancelUpdates.cancel_at) {
|
|
254
|
+
stripeUpdates.cancel_at = cancelUpdates.cancel_at;
|
|
255
|
+
} else if (cancelUpdates.cancel_at_period_end) {
|
|
256
|
+
stripeUpdates.cancel_at_period_end = true;
|
|
257
|
+
}
|
|
258
|
+
if (cancelSubscription) {
|
|
259
|
+
stripeUpdates.cancel_at = now;
|
|
260
|
+
stripeUpdates.cancel_at_period_end = false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await client.subscriptions.update(subscription.payment_details.stripe.subscription_id, stripeUpdates);
|
|
264
|
+
logger.info(`[${eventType}] Updated subscription in Stripe after payment failed`, {
|
|
265
|
+
subscription: subscription.id,
|
|
266
|
+
stripeSubscription: subscription.payment_details.stripe.subscription_id,
|
|
267
|
+
updates: stripeUpdates,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.error(`[${eventType}] Failed to update subscription in Stripe`, {
|
|
272
|
+
error: err,
|
|
273
|
+
subscription: subscription.id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -41,6 +41,7 @@ import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
|
|
|
41
41
|
import createQueue from '../libs/queue';
|
|
42
42
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
|
|
43
43
|
import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup } from '../libs/session';
|
|
44
|
+
import { syncStripeSubscriptionAfterRecovery } from '../integrations/stripe/handlers/subscription';
|
|
44
45
|
|
|
45
46
|
type PaymentJob = {
|
|
46
47
|
paymentIntentId: string;
|
|
@@ -246,6 +247,7 @@ export const handlePaymentSucceed = async (
|
|
|
246
247
|
cancelation_details: null,
|
|
247
248
|
});
|
|
248
249
|
logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel rest`);
|
|
250
|
+
await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
|
|
249
251
|
return;
|
|
250
252
|
}
|
|
251
253
|
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
@@ -267,11 +269,13 @@ export const handlePaymentSucceed = async (
|
|
|
267
269
|
logger.info(
|
|
268
270
|
`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel and billing cycle reset`
|
|
269
271
|
);
|
|
272
|
+
await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
|
|
270
273
|
} else if (subscription.cancel_at_period_end) {
|
|
271
274
|
// reset cancel_at_period_end if we are recovering from payment failed
|
|
272
275
|
// @ts-ignore
|
|
273
276
|
await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
|
|
274
277
|
logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel reset`);
|
|
278
|
+
await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
|
|
275
279
|
} else {
|
|
276
280
|
await subscription.update({ status: 'active' });
|
|
277
281
|
logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}`);
|
|
@@ -1742,30 +1742,37 @@ router.get('/:id/cycle-amount', authPortal, async (req, res) => {
|
|
|
1742
1742
|
if (!currency) {
|
|
1743
1743
|
return res.status(404).json({ error: 'Currency not found' });
|
|
1744
1744
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1745
|
+
try {
|
|
1746
|
+
// get upcoming invoice
|
|
1747
|
+
const result = await getUpcomingInvoiceAmount(subscription.id);
|
|
1748
|
+
// get past invoices
|
|
1749
|
+
const pastMaxAmount = await getPastInvoicesAmount(subscription.id, 'max');
|
|
1749
1750
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1751
|
+
// return max amount
|
|
1752
|
+
const nextAmount = new BN(result.amount === '0' ? result.minExpectedAmount : result.amount).toString();
|
|
1752
1753
|
|
|
1753
|
-
|
|
1754
|
+
const maxAmount = new BN(pastMaxAmount.amount).lte(new BN(nextAmount)) ? nextAmount : pastMaxAmount.amount;
|
|
1754
1755
|
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1756
|
+
if (req.query?.overdraftProtection) {
|
|
1757
|
+
const { price } = await ensureOverdraftProtectionPrice(subscription.livemode);
|
|
1758
|
+
const invoicePrice = (price?.currency_options || []).find(
|
|
1759
|
+
(x: any) => x.currency_id === subscription?.currency_id
|
|
1760
|
+
);
|
|
1761
|
+
const gas = invoicePrice?.unit_amount;
|
|
1762
|
+
return res.json({
|
|
1763
|
+
amount: new BN(maxAmount).add(new BN(gas)).toString(),
|
|
1764
|
+
gas,
|
|
1765
|
+
currency,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1759
1768
|
return res.json({
|
|
1760
|
-
amount:
|
|
1761
|
-
gas,
|
|
1769
|
+
amount: maxAmount,
|
|
1762
1770
|
currency,
|
|
1763
1771
|
});
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
logger.error(err);
|
|
1774
|
+
return res.status(400).json({ error: err.message });
|
|
1764
1775
|
}
|
|
1765
|
-
return res.json({
|
|
1766
|
-
amount: maxAmount,
|
|
1767
|
-
currency,
|
|
1768
|
-
});
|
|
1769
1776
|
});
|
|
1770
1777
|
|
|
1771
1778
|
// slash stake
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.39",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -45,30 +45,30 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@abtnode/cron": "^1.16.42",
|
|
48
|
-
"@arcblock/did": "^1.20.
|
|
48
|
+
"@arcblock/did": "^1.20.6",
|
|
49
49
|
"@arcblock/did-auth-storage-nedb": "^1.7.1",
|
|
50
|
-
"@arcblock/did-connect": "^2.13.
|
|
51
|
-
"@arcblock/did-util": "^1.20.
|
|
52
|
-
"@arcblock/jwt": "^1.20.
|
|
53
|
-
"@arcblock/ux": "^2.13.
|
|
54
|
-
"@arcblock/validator": "^1.20.
|
|
55
|
-
"@blocklet/did-space-js": "^1.0.
|
|
50
|
+
"@arcblock/did-connect": "^2.13.23",
|
|
51
|
+
"@arcblock/did-util": "^1.20.6",
|
|
52
|
+
"@arcblock/jwt": "^1.20.6",
|
|
53
|
+
"@arcblock/ux": "^2.13.23",
|
|
54
|
+
"@arcblock/validator": "^1.20.6",
|
|
55
|
+
"@blocklet/did-space-js": "^1.0.49",
|
|
56
56
|
"@blocklet/js-sdk": "^1.16.42",
|
|
57
57
|
"@blocklet/logger": "^1.16.42",
|
|
58
|
-
"@blocklet/payment-react": "1.18.
|
|
58
|
+
"@blocklet/payment-react": "1.18.39",
|
|
59
59
|
"@blocklet/sdk": "^1.16.42",
|
|
60
|
-
"@blocklet/ui-react": "^2.13.
|
|
61
|
-
"@blocklet/uploader": "^0.1.
|
|
62
|
-
"@blocklet/xss": "^0.1.
|
|
60
|
+
"@blocklet/ui-react": "^2.13.23",
|
|
61
|
+
"@blocklet/uploader": "^0.1.85",
|
|
62
|
+
"@blocklet/xss": "^0.1.34",
|
|
63
63
|
"@mui/icons-material": "^5.16.6",
|
|
64
64
|
"@mui/lab": "^5.0.0-alpha.173",
|
|
65
65
|
"@mui/material": "^5.16.6",
|
|
66
66
|
"@mui/system": "^5.16.6",
|
|
67
|
-
"@ocap/asset": "^1.20.
|
|
68
|
-
"@ocap/client": "^1.20.
|
|
69
|
-
"@ocap/mcrypto": "^1.20.
|
|
70
|
-
"@ocap/util": "^1.20.
|
|
71
|
-
"@ocap/wallet": "^1.20.
|
|
67
|
+
"@ocap/asset": "^1.20.6",
|
|
68
|
+
"@ocap/client": "^1.20.6",
|
|
69
|
+
"@ocap/mcrypto": "^1.20.6",
|
|
70
|
+
"@ocap/util": "^1.20.6",
|
|
71
|
+
"@ocap/wallet": "^1.20.6",
|
|
72
72
|
"@stripe/react-stripe-js": "^2.7.3",
|
|
73
73
|
"@stripe/stripe-js": "^2.4.0",
|
|
74
74
|
"ahooks": "^3.8.0",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@abtnode/types": "^1.16.42",
|
|
125
125
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
126
|
-
"@blocklet/payment-types": "1.18.
|
|
126
|
+
"@blocklet/payment-types": "1.18.39",
|
|
127
127
|
"@types/cookie-parser": "^1.4.7",
|
|
128
128
|
"@types/cors": "^2.8.17",
|
|
129
129
|
"@types/debug": "^4.1.12",
|
|
@@ -169,5 +169,5 @@
|
|
|
169
169
|
"parser": "typescript"
|
|
170
170
|
}
|
|
171
171
|
},
|
|
172
|
-
"gitHead": "
|
|
172
|
+
"gitHead": "9e9a5c18fc160094505e902e995f5a5a052d544f"
|
|
173
173
|
}
|
|
@@ -10,15 +10,18 @@ import {
|
|
|
10
10
|
getSubscriptionAction,
|
|
11
11
|
usePaymentContext,
|
|
12
12
|
OverdueInvoicePayment,
|
|
13
|
+
formatBNStr,
|
|
13
14
|
} from '@blocklet/payment-react';
|
|
14
15
|
import type { TSubscriptionExpanded } from '@blocklet/payment-types';
|
|
15
|
-
import { Button, Link, Stack, Tooltip } from '@mui/material';
|
|
16
|
+
import { Button, Link, Stack, Tooltip, Typography, Box, Alert } from '@mui/material';
|
|
16
17
|
import { useRequest, useSetState } from 'ahooks';
|
|
17
18
|
import isEmpty from 'lodash/isEmpty';
|
|
18
|
-
import { useEffect, useState } from 'react';
|
|
19
|
+
import { useEffect, useState, ReactNode } from 'react';
|
|
19
20
|
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
|
|
20
21
|
import { useNavigate } from 'react-router-dom';
|
|
21
22
|
import { joinURL } from 'ufo';
|
|
23
|
+
import { BN } from '@ocap/util';
|
|
24
|
+
import DID from '@arcblock/ux/lib/DID';
|
|
22
25
|
import CustomerCancelForm from './cancel';
|
|
23
26
|
import OverdraftProtectionDialog from '../../customer/overdraft-protection';
|
|
24
27
|
import Actions from '../../actions';
|
|
@@ -28,7 +31,7 @@ import { isWillCanceled } from '../../../libs/util';
|
|
|
28
31
|
interface ActionConfig {
|
|
29
32
|
key: string;
|
|
30
33
|
show: boolean;
|
|
31
|
-
label: string;
|
|
34
|
+
label: string | ReactNode | (() => ReactNode);
|
|
32
35
|
tooltip?: string;
|
|
33
36
|
onClick: (e?: React.MouseEvent) => void;
|
|
34
37
|
variant?: 'text' | 'outlined' | 'contained';
|
|
@@ -65,6 +68,7 @@ type Props = {
|
|
|
65
68
|
subscription: TSubscriptionExpanded;
|
|
66
69
|
showExtra?: boolean;
|
|
67
70
|
showRecharge?: boolean;
|
|
71
|
+
showBalanceInfo?: boolean;
|
|
68
72
|
showOverdraftProtection?:
|
|
69
73
|
| boolean
|
|
70
74
|
| {
|
|
@@ -82,6 +86,7 @@ type Props = {
|
|
|
82
86
|
SubscriptionActions.defaultProps = {
|
|
83
87
|
showExtra: false,
|
|
84
88
|
showRecharge: false,
|
|
89
|
+
showBalanceInfo: false,
|
|
85
90
|
showOverdraftProtection: false,
|
|
86
91
|
showDelegation: false,
|
|
87
92
|
onChange: null,
|
|
@@ -138,6 +143,7 @@ export function SubscriptionActionsInner({
|
|
|
138
143
|
actionProps,
|
|
139
144
|
mode,
|
|
140
145
|
setUp,
|
|
146
|
+
showBalanceInfo,
|
|
141
147
|
}: Props) {
|
|
142
148
|
const { t, locale } = useLocaleContext();
|
|
143
149
|
const { reset, getValues } = useFormContext();
|
|
@@ -149,6 +155,23 @@ export function SubscriptionActionsInner({
|
|
|
149
155
|
|
|
150
156
|
const { data: extraActions } = useRequest(() => fetchExtraActions({ id: subscription.id, showExtra: !!showExtra }));
|
|
151
157
|
|
|
158
|
+
const { data: upcoming = {} } = useRequest(
|
|
159
|
+
() => api.get(`/api/subscriptions/${subscription.id}/upcoming`).then((res) => res.data),
|
|
160
|
+
{
|
|
161
|
+
ready: !!(showRecharge && supportRecharge(subscription)) && showBalanceInfo,
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const { data: payerValue = {} } = useRequest(
|
|
166
|
+
() => api.get(`/api/subscriptions/${subscription.id}/payer-token`).then((res) => res.data),
|
|
167
|
+
{
|
|
168
|
+
ready: !!(showRecharge && supportRecharge(subscription)) && showBalanceInfo,
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const isInsufficientBalance =
|
|
173
|
+
upcoming?.amount && payerValue?.token && new BN(payerValue.token || '0').lt(new BN(upcoming.amount || '0'));
|
|
174
|
+
|
|
152
175
|
const [state, setState] = useSetState({
|
|
153
176
|
action: '',
|
|
154
177
|
subscription: '',
|
|
@@ -359,7 +382,73 @@ export function SubscriptionActionsInner({
|
|
|
359
382
|
{
|
|
360
383
|
key: 'recharge',
|
|
361
384
|
show: !!(showRecharge && supportRecharge(subscription)),
|
|
362
|
-
label:
|
|
385
|
+
label: () => {
|
|
386
|
+
const balanceDisplay = (
|
|
387
|
+
<Stack direction="row" spacing={0.5} alignItems="center">
|
|
388
|
+
{t('customer.recharge.title')}
|
|
389
|
+
</Stack>
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
if (showBalanceInfo && subscription.paymentCurrency) {
|
|
393
|
+
const formattedBalance = formatBNStr(payerValue?.token || '0', subscription.paymentCurrency.decimal);
|
|
394
|
+
const formattedUpcoming = formatBNStr(upcoming?.amount || '0', subscription.paymentCurrency.decimal);
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<Tooltip
|
|
398
|
+
componentsProps={{
|
|
399
|
+
tooltip: {
|
|
400
|
+
sx: {
|
|
401
|
+
bgcolor: 'background.paper',
|
|
402
|
+
color: 'text.primary',
|
|
403
|
+
boxShadow: 2,
|
|
404
|
+
padding: '10px 16px',
|
|
405
|
+
maxWidth: 480,
|
|
406
|
+
minWidth: 350,
|
|
407
|
+
wordBreak: 'break-word',
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
}}
|
|
411
|
+
title={
|
|
412
|
+
<Box>
|
|
413
|
+
{isInsufficientBalance && (
|
|
414
|
+
<Alert severity="error" sx={{ py: 0, mb: 1 }}>
|
|
415
|
+
{t('admin.subscription.insufficientBalance')}
|
|
416
|
+
</Alert>
|
|
417
|
+
)}
|
|
418
|
+
<Stack spacing={0.5}>
|
|
419
|
+
<Box display="flex" justifyContent="space-between">
|
|
420
|
+
<Typography sx={{ color: 'text.secondary' }}>
|
|
421
|
+
{t('admin.subscription.currentBalance')}
|
|
422
|
+
</Typography>
|
|
423
|
+
<Typography>
|
|
424
|
+
{formattedBalance} {subscription.paymentCurrency.symbol}
|
|
425
|
+
</Typography>
|
|
426
|
+
</Box>
|
|
427
|
+
<Box display="flex" justifyContent="space-between">
|
|
428
|
+
<Typography sx={{ color: 'text.secondary' }}>
|
|
429
|
+
{t('admin.subscription.nextInvoiceAmount')}
|
|
430
|
+
</Typography>
|
|
431
|
+
<Typography>
|
|
432
|
+
{formattedUpcoming} {subscription.paymentCurrency.symbol}
|
|
433
|
+
</Typography>
|
|
434
|
+
</Box>
|
|
435
|
+
<Box display="flex" justifyContent="space-between">
|
|
436
|
+
<Typography sx={{ color: 'text.secondary' }}>
|
|
437
|
+
{t('admin.subscription.paymentAddress')}
|
|
438
|
+
</Typography>
|
|
439
|
+
<DID did={payerValue?.paymentAddress} responsive={false} compact showAvatar={false} />
|
|
440
|
+
</Box>
|
|
441
|
+
</Stack>
|
|
442
|
+
</Box>
|
|
443
|
+
}
|
|
444
|
+
placement="top">
|
|
445
|
+
{balanceDisplay}
|
|
446
|
+
</Tooltip>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return balanceDisplay;
|
|
451
|
+
},
|
|
363
452
|
onClick: (e) => {
|
|
364
453
|
e?.stopPropagation();
|
|
365
454
|
navigate(`/customer/subscription/${subscription.id}/recharge`);
|
|
@@ -367,6 +456,22 @@ export function SubscriptionActionsInner({
|
|
|
367
456
|
variant: 'outlined',
|
|
368
457
|
color: 'primary',
|
|
369
458
|
primary: !isWillCanceled(subscription),
|
|
459
|
+
sx:
|
|
460
|
+
isInsufficientBalance || payerValue.token === '0'
|
|
461
|
+
? {
|
|
462
|
+
'&::before': {
|
|
463
|
+
content: '""',
|
|
464
|
+
backgroundColor: 'error.main',
|
|
465
|
+
borderRadius: '50%',
|
|
466
|
+
position: 'absolute',
|
|
467
|
+
top: '-4px',
|
|
468
|
+
right: '-4px',
|
|
469
|
+
width: '8px',
|
|
470
|
+
height: '8px',
|
|
471
|
+
zIndex: 1,
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
: {},
|
|
370
475
|
},
|
|
371
476
|
{
|
|
372
477
|
key: 'changePlan',
|
|
@@ -442,26 +547,30 @@ export function SubscriptionActionsInner({
|
|
|
442
547
|
divider: item.divider,
|
|
443
548
|
});
|
|
444
549
|
|
|
445
|
-
const toButton = (item: ActionConfig) =>
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
550
|
+
const toButton = (item: ActionConfig) => {
|
|
551
|
+
const labelContent = typeof item.label === 'function' ? item.label() : item.label;
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<Button
|
|
555
|
+
key={item.key}
|
|
556
|
+
variant={item.variant}
|
|
557
|
+
color={item.color}
|
|
558
|
+
onClick={item.onClick}
|
|
559
|
+
component={item.component}
|
|
560
|
+
href={item.href}
|
|
561
|
+
target={item.target}
|
|
562
|
+
sx={item.sx}
|
|
563
|
+
size="small">
|
|
564
|
+
{item.tooltip ? (
|
|
565
|
+
<Tooltip title={item.tooltip}>
|
|
566
|
+
<span>{labelContent}</span>
|
|
567
|
+
</Tooltip>
|
|
568
|
+
) : (
|
|
569
|
+
labelContent
|
|
570
|
+
)}
|
|
571
|
+
</Button>
|
|
572
|
+
);
|
|
573
|
+
};
|
|
465
574
|
if (mode === 'menu-only') {
|
|
466
575
|
return <Actions actions={visibleActions.map(toMenuItem)} variant="outlined" />;
|
|
467
576
|
}
|
|
@@ -566,6 +675,7 @@ SubscriptionActionsInner.defaultProps = {
|
|
|
566
675
|
showExtra: false,
|
|
567
676
|
showRecharge: false,
|
|
568
677
|
showOverdraftProtection: false,
|
|
678
|
+
showBalanceInfo: false,
|
|
569
679
|
showDelegation: false,
|
|
570
680
|
showUnsubscribe: true,
|
|
571
681
|
onChange: null,
|
|
@@ -120,7 +120,7 @@ export default function CurrentSubscriptions({
|
|
|
120
120
|
}}
|
|
121
121
|
sx={{
|
|
122
122
|
padding: 1.5,
|
|
123
|
-
|
|
123
|
+
backgroundColor: 'grey.50',
|
|
124
124
|
'&:hover': {
|
|
125
125
|
backgroundColor: 'grey.100',
|
|
126
126
|
transition: 'background-color 200ms linear',
|
|
@@ -216,6 +216,7 @@ export default function CurrentSubscriptions({
|
|
|
216
216
|
}}
|
|
217
217
|
showUnsubscribe={false}
|
|
218
218
|
showRecharge={!isWillCanceled(subscription)}
|
|
219
|
+
showBalanceInfo
|
|
219
220
|
actionProps={{
|
|
220
221
|
cancel: {
|
|
221
222
|
variant: 'outlined',
|
|
@@ -140,7 +140,10 @@ export default function PaymentIntentDetail(props: { id: string }) {
|
|
|
140
140
|
sx={{ width: '52px', height: '52px', borderRadius: 0.5 }}
|
|
141
141
|
/>
|
|
142
142
|
<Stack direction="column" alignItems="flex-start" justifyContent="space-around">
|
|
143
|
-
<Amount
|
|
143
|
+
<Amount
|
|
144
|
+
amount={data?.amount_received === '0' ? total : received}
|
|
145
|
+
sx={{ my: 0, fontSize: '1.75rem', lineHeight: '32px' }}
|
|
146
|
+
/>
|
|
144
147
|
<Copyable text={props.id} />
|
|
145
148
|
</Stack>
|
|
146
149
|
</Stack>
|
|
@@ -74,12 +74,14 @@ export default function CustomerSubscriptionDetail() {
|
|
|
74
74
|
const { session } = useSessionContext();
|
|
75
75
|
const { loading, error, data, refresh } = useRequest(() => fetchData(id));
|
|
76
76
|
const { hasUnpaid, checkUnpaidInvoices } = useUnpaidInvoicesCheckForSubscription(id);
|
|
77
|
+
const overdraftProtectionReady =
|
|
78
|
+
['active', 'trialing', 'past_due'].includes(data?.status || '') && data?.paymentMethod?.type === 'arcblock';
|
|
77
79
|
const {
|
|
78
80
|
data: overdraftProtection = null,
|
|
79
81
|
loading: overdraftProtectionLoading,
|
|
80
82
|
run: refreshOverdraftProtection,
|
|
81
83
|
} = useRequest(() => fetchOverdraftProtection(id), {
|
|
82
|
-
ready:
|
|
84
|
+
ready: overdraftProtectionReady,
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
const {
|
|
@@ -94,6 +96,7 @@ export default function CustomerSubscriptionDetail() {
|
|
|
94
96
|
}),
|
|
95
97
|
{
|
|
96
98
|
refreshDeps: [id],
|
|
99
|
+
ready: overdraftProtectionReady,
|
|
97
100
|
}
|
|
98
101
|
);
|
|
99
102
|
|