payment-kit 1.13.266 → 1.13.267
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/libs/auth.ts +2 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +4 -3
- package/api/src/libs/subscription.ts +136 -0
- package/api/src/queues/invoice.ts +5 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/collect.ts +12 -0
- package/api/src/routes/connect/shared.ts +25 -2
- package/api/src/routes/subscriptions.ts +77 -97
- package/api/src/store/models/subscription.ts +1 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/pages/admin/products/passports/index.tsx +1 -4
- package/src/pages/customer/subscription/change-plan.tsx +11 -11
package/api/src/libs/auth.ts
CHANGED
|
@@ -82,9 +82,10 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
82
82
|
const productName = await getMainProductName(subscription.id);
|
|
83
83
|
const at: string = formatTime(subscription.created_at);
|
|
84
84
|
|
|
85
|
-
const paymentInfo: string = `${fromUnitToToken(
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
const paymentInfo: string = `${fromUnitToToken(
|
|
86
|
+
paymentIntent?.amount || invoice.amount_paid,
|
|
87
|
+
paymentCurrency.decimal
|
|
88
|
+
)} ${paymentCurrency.symbol}`;
|
|
88
89
|
const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
|
|
89
90
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
90
91
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* eslint-disable no-await-in-loop */
|
|
2
2
|
import component from '@blocklet/sdk/lib/component';
|
|
3
3
|
import { BN } from '@ocap/util';
|
|
4
|
+
import isEmpty from 'lodash/isEmpty';
|
|
5
|
+
import pick from 'lodash/pick';
|
|
4
6
|
import type { LiteralUnion } from 'type-fest';
|
|
5
7
|
import { withQuery } from 'ufo';
|
|
6
8
|
|
|
@@ -8,11 +10,13 @@ import {
|
|
|
8
10
|
Customer,
|
|
9
11
|
Invoice,
|
|
10
12
|
InvoiceItem,
|
|
13
|
+
Lock,
|
|
11
14
|
PaymentCurrency,
|
|
12
15
|
Price,
|
|
13
16
|
PriceRecurring,
|
|
14
17
|
Subscription,
|
|
15
18
|
SubscriptionItem,
|
|
19
|
+
SubscriptionUpdateItem,
|
|
16
20
|
TLineItemExpanded,
|
|
17
21
|
UsageRecord,
|
|
18
22
|
} from '../store/models';
|
|
@@ -427,3 +431,135 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
|
|
|
427
431
|
currency,
|
|
428
432
|
};
|
|
429
433
|
}
|
|
434
|
+
|
|
435
|
+
export async function finalizeSubscriptionUpdate({
|
|
436
|
+
subscription,
|
|
437
|
+
customer,
|
|
438
|
+
invoice,
|
|
439
|
+
paymentCurrency,
|
|
440
|
+
appliedCredit,
|
|
441
|
+
newCredit,
|
|
442
|
+
addedItems,
|
|
443
|
+
updatedItems,
|
|
444
|
+
deletedItems,
|
|
445
|
+
updates,
|
|
446
|
+
}: {
|
|
447
|
+
subscription: Subscription;
|
|
448
|
+
customer: Customer;
|
|
449
|
+
invoice: Invoice;
|
|
450
|
+
paymentCurrency: PaymentCurrency;
|
|
451
|
+
appliedCredit: string;
|
|
452
|
+
newCredit: string;
|
|
453
|
+
addedItems: SubscriptionUpdateItem[];
|
|
454
|
+
updatedItems: SubscriptionUpdateItem[];
|
|
455
|
+
deletedItems: SubscriptionUpdateItem[];
|
|
456
|
+
updates: any;
|
|
457
|
+
}) {
|
|
458
|
+
if (isEmpty(updates)) {
|
|
459
|
+
logger.info('subscription update aborted', { subscription: subscription.id, updates });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
logger.info('subscription update finalized', { subscription: subscription.id, updates });
|
|
464
|
+
await subscription.update({ ...updates, pending_update: null });
|
|
465
|
+
|
|
466
|
+
// update subscription items
|
|
467
|
+
for (const item of addedItems) {
|
|
468
|
+
await SubscriptionItem.create({
|
|
469
|
+
price_id: item.price_id as string,
|
|
470
|
+
quantity: item.quantity as number,
|
|
471
|
+
livemode: subscription.livemode,
|
|
472
|
+
subscription_id: subscription.id,
|
|
473
|
+
metadata: {},
|
|
474
|
+
});
|
|
475
|
+
logger.info('subscription item added on update finalize', { subscription: subscription.id, item: item.id });
|
|
476
|
+
}
|
|
477
|
+
for (const item of updatedItems) {
|
|
478
|
+
await SubscriptionItem.update(pick(item, ['quantity', 'metadata', 'billing_thresholds']), {
|
|
479
|
+
where: { id: item.id },
|
|
480
|
+
});
|
|
481
|
+
logger.info('subscription item updated on update finalize', { subscription: subscription.id, item: item.id });
|
|
482
|
+
}
|
|
483
|
+
for (const item of deletedItems) {
|
|
484
|
+
if (item.clear_usage) {
|
|
485
|
+
await UsageRecord.destroy({ where: { subscription_item_id: item.id } });
|
|
486
|
+
logger.info('subscription item usage cleared on update finalize', {
|
|
487
|
+
subscription: subscription.id,
|
|
488
|
+
item: item.id,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
await SubscriptionItem.destroy({ where: { id: item.id } });
|
|
492
|
+
logger.info('subscription item deleted on update finalize', { subscription: subscription.id, item: item.id });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// update customer credits
|
|
496
|
+
if (appliedCredit !== '0') {
|
|
497
|
+
const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
|
|
498
|
+
await invoice.update({
|
|
499
|
+
starting_token_balance: creditResult.starting,
|
|
500
|
+
ending_token_balance: creditResult.ending,
|
|
501
|
+
});
|
|
502
|
+
logger.info('customer credit applied to invoice after proration', {
|
|
503
|
+
subscription: subscription.id,
|
|
504
|
+
appliedCredit,
|
|
505
|
+
creditResult,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (newCredit !== '0') {
|
|
509
|
+
const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
|
|
510
|
+
await invoice.update({
|
|
511
|
+
starting_token_balance: creditResult.starting,
|
|
512
|
+
ending_token_balance: creditResult.ending,
|
|
513
|
+
});
|
|
514
|
+
logger.info('subscription proration credit applied to customer', {
|
|
515
|
+
subscription: subscription.id,
|
|
516
|
+
newCredit,
|
|
517
|
+
creditResult,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// lock for next update
|
|
522
|
+
const releaseAt = subscription.current_period_end;
|
|
523
|
+
await Lock.acquire(`${subscription.id}-change-plan`, releaseAt);
|
|
524
|
+
logger.info('subscription plan change lock acquired on finalize', { subscription: subscription.id, releaseAt });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function onSubscriptionUpdateConnected(subscriptionId: string) {
|
|
528
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
529
|
+
if (!subscription) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const now = dayjs().unix();
|
|
534
|
+
const pending = subscription.pending_update;
|
|
535
|
+
logger.info('subscription update connected', { subscription: subscription.id, pending });
|
|
536
|
+
if (pending?.updates && pending?.expires_at && pending.expires_at >= now && pending.updates?.latest_invoice_id) {
|
|
537
|
+
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
538
|
+
if (!paymentCurrency) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
543
|
+
if (!customer) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const invoice = await Invoice.findByPk(pending.updates.latest_invoice_id);
|
|
548
|
+
if (!invoice) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
await finalizeSubscriptionUpdate({
|
|
553
|
+
subscription,
|
|
554
|
+
customer,
|
|
555
|
+
invoice,
|
|
556
|
+
paymentCurrency,
|
|
557
|
+
appliedCredit: pending.appliedCredit || '0',
|
|
558
|
+
newCredit: pending.newCredit || '0',
|
|
559
|
+
addedItems: pending.addedItems || [],
|
|
560
|
+
deletedItems: pending.deletedItems || [],
|
|
561
|
+
updatedItems: pending.updatedItems || [],
|
|
562
|
+
updates: pending.updates,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
@@ -14,6 +14,7 @@ import { paymentQueue } from './payment';
|
|
|
14
14
|
|
|
15
15
|
type InvoiceJob = {
|
|
16
16
|
invoiceId: string;
|
|
17
|
+
justCreate?: boolean;
|
|
17
18
|
retryOnError?: boolean;
|
|
18
19
|
waitForPayment?: boolean;
|
|
19
20
|
};
|
|
@@ -159,6 +160,10 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
if (paymentIntent) {
|
|
163
|
+
if (job.justCreate) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
162
167
|
logger.info('Payment job scheduled', { invoice: invoice.id, paymentIntent: paymentIntent.id });
|
|
163
168
|
if (job.waitForPayment) {
|
|
164
169
|
await paymentQueue.pushAndWait({
|
|
@@ -2,6 +2,7 @@ import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/e
|
|
|
2
2
|
import type { CallbackArgs } from '../../libs/auth';
|
|
3
3
|
import { isDelegationSufficientForPayment } from '../../libs/payment';
|
|
4
4
|
import { getFastCheckoutAmount } from '../../libs/session';
|
|
5
|
+
import { onSubscriptionUpdateConnected } from '../../libs/subscription';
|
|
5
6
|
import { getTxMetadata } from '../../libs/util';
|
|
6
7
|
import { invoiceQueue } from '../../queues/invoice';
|
|
7
8
|
import { addSubscriptionJob } from '../../queues/subscription';
|
|
@@ -132,6 +133,7 @@ export default {
|
|
|
132
133
|
});
|
|
133
134
|
}
|
|
134
135
|
if (subscription) {
|
|
136
|
+
await onSubscriptionUpdateConnected(subscriptionId);
|
|
135
137
|
await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
|
|
136
138
|
}
|
|
137
139
|
};
|
|
@@ -8,6 +8,7 @@ import type { CallbackArgs } from '../../libs/auth';
|
|
|
8
8
|
import { ethWallet, wallet } from '../../libs/auth';
|
|
9
9
|
import logger from '../../libs/logger';
|
|
10
10
|
import { getGasPayerExtra } from '../../libs/payment';
|
|
11
|
+
import { onSubscriptionUpdateConnected } from '../../libs/subscription';
|
|
11
12
|
import { getTxMetadata } from '../../libs/util';
|
|
12
13
|
import { invoiceQueue } from '../../queues/invoice';
|
|
13
14
|
import { handlePaymentSucceed, paymentQueue } from '../../queues/payment';
|
|
@@ -122,6 +123,17 @@ export default {
|
|
|
122
123
|
if (exist) {
|
|
123
124
|
await invoiceQueue.delete(invoice.id);
|
|
124
125
|
}
|
|
126
|
+
|
|
127
|
+
if (invoice.subscription_id && invoice.billing_reason === 'subscription_update') {
|
|
128
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
129
|
+
if (subscription?.pending_update?.updates?.latest_invoice_id === invoice.id) {
|
|
130
|
+
logger.info('Try to finalize subscription update on invoice paid', {
|
|
131
|
+
invoice: invoice.id,
|
|
132
|
+
subscription: subscription.id,
|
|
133
|
+
});
|
|
134
|
+
await onSubscriptionUpdateConnected(invoice.subscription_id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
125
137
|
};
|
|
126
138
|
|
|
127
139
|
if (paymentMethod.type === 'arcblock') {
|
|
@@ -5,8 +5,8 @@ import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util';
|
|
|
5
5
|
import type { Transaction } from '@ocap/client';
|
|
6
6
|
import { BN, fromTokenToUnit, toBase58 } from '@ocap/util';
|
|
7
7
|
import { fromPublicKey } from '@ocap/wallet';
|
|
8
|
-
import isEmpty from 'lodash/isEmpty';
|
|
9
8
|
import type { Request } from 'express';
|
|
9
|
+
import isEmpty from 'lodash/isEmpty';
|
|
10
10
|
|
|
11
11
|
import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/arcblock/stake';
|
|
12
12
|
import { encodeApproveItx } from '../../integrations/ethereum/token';
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getSubscriptionStakeSetup,
|
|
23
23
|
} from '../../libs/subscription';
|
|
24
24
|
import { OCAP_PAYMENT_TX_TYPE } from '../../libs/util';
|
|
25
|
+
import { invoiceQueue } from '../../queues/invoice';
|
|
25
26
|
import type { TLineItemExpanded } from '../../store/models';
|
|
26
27
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
27
28
|
import { Customer } from '../../store/models/customer';
|
|
@@ -504,6 +505,20 @@ export async function ensureInvoiceAndItems({
|
|
|
504
505
|
return { invoice, items };
|
|
505
506
|
}
|
|
506
507
|
|
|
508
|
+
export async function cleanupInvoiceAndItems(invoiceId: string) {
|
|
509
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
510
|
+
if (!invoice) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (invoice.isImmutable()) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const removedItem = await InvoiceItem.destroy({ where: { invoice_id: invoiceId } });
|
|
518
|
+
const removedInvoice = await Invoice.destroy({ where: { id: invoiceId } });
|
|
519
|
+
logger.info('cleanup invoice and items', { invoiceId, removedItem, removedInvoice });
|
|
520
|
+
}
|
|
521
|
+
|
|
507
522
|
export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
508
523
|
const invoice = await Invoice.findByPk(invoiceId);
|
|
509
524
|
if (!invoice) {
|
|
@@ -519,6 +534,14 @@ export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
|
519
534
|
throw new Error(`Invoice ${invoiceId} is draft`);
|
|
520
535
|
}
|
|
521
536
|
|
|
537
|
+
if (!invoice.payment_intent_id) {
|
|
538
|
+
await invoiceQueue.pushAndWait({
|
|
539
|
+
id: invoice.id,
|
|
540
|
+
job: { invoiceId: invoice.id, retryOnError: false, justCreate: true },
|
|
541
|
+
});
|
|
542
|
+
await invoice.reload();
|
|
543
|
+
}
|
|
544
|
+
|
|
522
545
|
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
523
546
|
if (!paymentIntent) {
|
|
524
547
|
throw new Error(`Payment intent not found for invoice ${invoiceId}`);
|
|
@@ -922,7 +945,7 @@ export async function executeOcapTransactions(
|
|
|
922
945
|
userPk: string,
|
|
923
946
|
claims: any[],
|
|
924
947
|
paymentMethod: PaymentMethod,
|
|
925
|
-
request: Request
|
|
948
|
+
request: Request
|
|
926
949
|
) {
|
|
927
950
|
const client = paymentMethod.getOcapClient();
|
|
928
951
|
const delegation = claims.find((x) => x.type === 'signature' && x.meta?.purpose === 'delegation');
|
|
@@ -14,6 +14,7 @@ import { authenticate } from '../libs/security';
|
|
|
14
14
|
import { expandLineItems, getFastCheckoutAmount, isLineItemAligned } from '../libs/session';
|
|
15
15
|
import {
|
|
16
16
|
createProration,
|
|
17
|
+
finalizeSubscriptionUpdate,
|
|
17
18
|
getSubscriptionCreateSetup,
|
|
18
19
|
getSubscriptionRefundSetup,
|
|
19
20
|
getUpcomingInvoiceAmount,
|
|
@@ -38,7 +39,7 @@ import { Subscription, TSubscription } from '../store/models/subscription';
|
|
|
38
39
|
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
39
40
|
import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '../store/models/types';
|
|
40
41
|
import { UsageRecord } from '../store/models/usage-record';
|
|
41
|
-
import { ensureInvoiceAndItems } from './connect/shared';
|
|
42
|
+
import { cleanupInvoiceAndItems, ensureInvoiceAndItems } from './connect/shared';
|
|
42
43
|
import { createUsageRecordQueryFn } from './usage-records';
|
|
43
44
|
|
|
44
45
|
const router = Router();
|
|
@@ -482,10 +483,10 @@ const validateSubscriptionUpdateRequest = async (subscription: Subscription, ite
|
|
|
482
483
|
}
|
|
483
484
|
|
|
484
485
|
// split items into added, deleted
|
|
486
|
+
const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
485
487
|
const addedItems = items.filter((x: any) => x.price_id && !x.id);
|
|
486
488
|
const deletedItems = items.filter((x: any) => x.deleted && x.id);
|
|
487
|
-
const updatedItems = items.filter((x: any) => !x.deleted && x.id);
|
|
488
|
-
const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
489
|
+
const updatedItems = items.filter((x: any) => !x.deleted && x.id && existingItems.some((i) => i.id === x.id));
|
|
489
490
|
|
|
490
491
|
// try handle cross-sell with different interval, just replace with new price that have same interval
|
|
491
492
|
let addedExpanded = await Price.expand(addedItems as LineItem[]);
|
|
@@ -569,7 +570,6 @@ const validateSubscriptionUpdateRequest = async (subscription: Subscription, ite
|
|
|
569
570
|
}
|
|
570
571
|
|
|
571
572
|
return {
|
|
572
|
-
existingItems,
|
|
573
573
|
addedItems,
|
|
574
574
|
updatedItems,
|
|
575
575
|
deletedItems,
|
|
@@ -692,35 +692,10 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
692
692
|
}
|
|
693
693
|
|
|
694
694
|
// validate the request
|
|
695
|
-
const {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
for (const item of addedItems) {
|
|
700
|
-
await SubscriptionItem.create({
|
|
701
|
-
price_id: item.price_id as string,
|
|
702
|
-
quantity: item.quantity as number,
|
|
703
|
-
livemode: subscription.livemode,
|
|
704
|
-
subscription_id: subscription.id,
|
|
705
|
-
metadata: {},
|
|
706
|
-
});
|
|
707
|
-
logger.info('subscription item added', { subscription: req.params.id, item: item.id });
|
|
708
|
-
}
|
|
709
|
-
for (const item of updatedItems) {
|
|
710
|
-
const exist = existingItems.find((x) => x.id === item.id);
|
|
711
|
-
if (exist) {
|
|
712
|
-
await exist.update(pick(item, ['quantity', 'metadata', 'billing_thresholds']));
|
|
713
|
-
logger.info('subscription item updated', { subscription: req.params.id, item: item.id });
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
for (const item of deletedItems) {
|
|
717
|
-
if (item.clear_usage) {
|
|
718
|
-
await UsageRecord.destroy({ where: { subscription_item_id: item.id } });
|
|
719
|
-
logger.info('subscription item usage cleared', { subscription: req.params.id, item: item.id });
|
|
720
|
-
}
|
|
721
|
-
await SubscriptionItem.destroy({ where: { id: item.id } });
|
|
722
|
-
logger.info('subscription item deleted', { subscription: req.params.id, item: item.id });
|
|
723
|
-
}
|
|
695
|
+
const { addedItems, updatedItems, deletedItems, newItems } = await validateSubscriptionUpdateRequest(
|
|
696
|
+
subscription,
|
|
697
|
+
value.items
|
|
698
|
+
);
|
|
724
699
|
|
|
725
700
|
// update subscription period settings
|
|
726
701
|
// HINT: if we are adding new items, we need to reset the anchor to now
|
|
@@ -736,6 +711,13 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
736
711
|
// handle proration
|
|
737
712
|
const prorationBehavior = updates.proration_behavior || subscription.proration_behavior || 'none';
|
|
738
713
|
if (prorationBehavior === 'create_prorations') {
|
|
714
|
+
// 0. cleanup open invoices
|
|
715
|
+
if (subscription.pending_update?.updates?.latest_invoice_id) {
|
|
716
|
+
await cleanupInvoiceAndItems(subscription.pending_update?.updates?.latest_invoice_id);
|
|
717
|
+
// @ts-ignore
|
|
718
|
+
await subscription.update({ pending_update: null });
|
|
719
|
+
}
|
|
720
|
+
|
|
739
721
|
// 1. create proration
|
|
740
722
|
const { lastInvoice, due, newCredit, appliedCredit, prorations } = await createProration(
|
|
741
723
|
subscription,
|
|
@@ -797,86 +779,84 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
797
779
|
items: prorationInvoiceItems.map((x) => x.id),
|
|
798
780
|
});
|
|
799
781
|
|
|
800
|
-
// 5.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
logger.info('customer credit applied to invoice after proration', {
|
|
811
|
-
subscription: req.params.id,
|
|
812
|
-
appliedCredit,
|
|
813
|
-
creditResult,
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
if (newCredit !== '0') {
|
|
817
|
-
const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
|
|
818
|
-
invoiceUpdates.starting_token_balance = creditResult.starting;
|
|
819
|
-
invoiceUpdates.ending_token_balance = creditResult.ending;
|
|
820
|
-
logger.info('subscription proration credit applied to customer', {
|
|
821
|
-
subscription: req.params.id,
|
|
822
|
-
newCredit,
|
|
823
|
-
creditResult,
|
|
782
|
+
// 5. check do we need to connect
|
|
783
|
+
let hasNext = true;
|
|
784
|
+
if (due === '0') {
|
|
785
|
+
hasNext = false;
|
|
786
|
+
} else {
|
|
787
|
+
const delegation = await isDelegationSufficientForPayment({
|
|
788
|
+
paymentMethod,
|
|
789
|
+
paymentCurrency,
|
|
790
|
+
userDid: customer.did,
|
|
791
|
+
amount: setup.amount.setup,
|
|
824
792
|
});
|
|
793
|
+
if (delegation.sufficient) {
|
|
794
|
+
hasNext = false;
|
|
795
|
+
} else if (['NO_DID_WALLET'].includes(delegation.reason as string)) {
|
|
796
|
+
throw new Error('Subscription update can only be done when you do have connected DID Wallet');
|
|
797
|
+
} else if (['NO_TOKEN', 'NO_ENOUGH_TOKEN'].includes(delegation.reason as string)) {
|
|
798
|
+
// FIXME: this is not supported at frontend
|
|
799
|
+
connectAction = 'collect';
|
|
800
|
+
} else {
|
|
801
|
+
connectAction = 'change-plan';
|
|
802
|
+
}
|
|
825
803
|
}
|
|
826
804
|
|
|
827
|
-
|
|
828
|
-
await
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
id: invoice.id,
|
|
833
|
-
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
805
|
+
// 6. adjust invoice total
|
|
806
|
+
await invoice.update({
|
|
807
|
+
status: 'open',
|
|
808
|
+
amount_due: due,
|
|
809
|
+
amount_remaining: due,
|
|
834
810
|
});
|
|
835
|
-
logger.info('subscription update invoice processed', { subscription: subscription.id, invoice: invoice.id });
|
|
836
|
-
|
|
837
|
-
// check if we have succeeded
|
|
838
|
-
await Promise.all([invoice.reload(), subscription.reload()]);
|
|
839
811
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
|
|
843
|
-
} else {
|
|
812
|
+
// 7. wait for succeed
|
|
813
|
+
if (hasNext) {
|
|
844
814
|
await subscription.update({
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
815
|
+
pending_update: {
|
|
816
|
+
expires_at: dayjs().unix() + 30 * 60, // after 30 minutes
|
|
817
|
+
updates,
|
|
818
|
+
appliedCredit,
|
|
819
|
+
newCredit,
|
|
820
|
+
addedItems,
|
|
821
|
+
deletedItems,
|
|
822
|
+
updatedItems,
|
|
851
823
|
},
|
|
852
824
|
});
|
|
853
|
-
logger.info('subscription
|
|
825
|
+
logger.info('subscription update invoice wait for connect', {
|
|
854
826
|
subscription: subscription.id,
|
|
855
827
|
invoice: invoice.id,
|
|
856
828
|
});
|
|
829
|
+
} else {
|
|
830
|
+
await invoiceQueue.pushAndWait({
|
|
831
|
+
id: invoice.id,
|
|
832
|
+
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
833
|
+
});
|
|
834
|
+
logger.info('subscription update invoice processed', { subscription: subscription.id, invoice: invoice.id });
|
|
857
835
|
|
|
858
|
-
|
|
859
|
-
|
|
836
|
+
// check if we have succeeded
|
|
837
|
+
await Promise.all([invoice.reload(), subscription.reload()]);
|
|
838
|
+
|
|
839
|
+
if (invoice.status === 'paid') {
|
|
840
|
+
await subscriptionQueue.delete(subscription.id);
|
|
841
|
+
await addSubscriptionJob(subscription, 'cycle', false, subscription.trial_end);
|
|
842
|
+
} else {
|
|
843
|
+
throw new Error('Subscription update invoice failed to advance');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
await finalizeSubscriptionUpdate({
|
|
847
|
+
subscription,
|
|
848
|
+
customer,
|
|
849
|
+
invoice,
|
|
860
850
|
paymentCurrency,
|
|
861
|
-
|
|
862
|
-
|
|
851
|
+
appliedCredit,
|
|
852
|
+
newCredit,
|
|
853
|
+
addedItems,
|
|
854
|
+
deletedItems,
|
|
855
|
+
updatedItems,
|
|
856
|
+
updates,
|
|
863
857
|
});
|
|
864
|
-
if (delegation.sufficient === false) {
|
|
865
|
-
if (['NO_DID_WALLET'].includes(delegation.reason as string)) {
|
|
866
|
-
connectAction = 'bind';
|
|
867
|
-
} else if (['NO_TOKEN', 'NO_ENOUGH_TOKEN'].includes(delegation.reason as string)) {
|
|
868
|
-
connectAction = 'collect';
|
|
869
|
-
} else {
|
|
870
|
-
connectAction = 'change-plan';
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
858
|
}
|
|
874
859
|
}
|
|
875
|
-
|
|
876
|
-
// rate limit for plan change
|
|
877
|
-
const releaseAt = updates.current_period_end || subscription.current_period_end;
|
|
878
|
-
await Lock.acquire(`${subscription.id}-change-plan`, releaseAt);
|
|
879
|
-
logger.info('subscription plan change lock acquired', { subscription: req.params.id, releaseAt });
|
|
880
860
|
} else if (req.body.billing_cycle_anchor === 'now') {
|
|
881
861
|
if (subscription.isActive() === false) {
|
|
882
862
|
throw new Error('Updating billing_cycle_anchor not allowed for inactive subscriptions');
|
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.267",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@arcblock/ux": "^2.9.80",
|
|
52
52
|
"@arcblock/validator": "^1.18.120",
|
|
53
53
|
"@blocklet/logger": "1.16.26",
|
|
54
|
-
"@blocklet/payment-react": "1.13.
|
|
54
|
+
"@blocklet/payment-react": "1.13.267",
|
|
55
55
|
"@blocklet/sdk": "1.16.26",
|
|
56
56
|
"@blocklet/ui-react": "^2.9.80",
|
|
57
57
|
"@blocklet/uploader": "^0.1.6",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"devDependencies": {
|
|
117
117
|
"@abtnode/types": "1.16.26",
|
|
118
118
|
"@arcblock/eslint-config-ts": "^0.3.0",
|
|
119
|
-
"@blocklet/payment-types": "1.13.
|
|
119
|
+
"@blocklet/payment-types": "1.13.267",
|
|
120
120
|
"@types/cookie-parser": "^1.4.7",
|
|
121
121
|
"@types/cors": "^2.8.17",
|
|
122
122
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -155,5 +155,5 @@
|
|
|
155
155
|
"parser": "typescript"
|
|
156
156
|
}
|
|
157
157
|
},
|
|
158
|
-
"gitHead": "
|
|
158
|
+
"gitHead": "5cbea674669735c0e8c8d8106038106938668c4b"
|
|
159
159
|
}
|
|
@@ -77,10 +77,7 @@ export default function PassportList() {
|
|
|
77
77
|
customBodyRenderLite: (_: string, index: number) => {
|
|
78
78
|
const payLink = data[index].extra?.acquire?.pay;
|
|
79
79
|
if (payLink) {
|
|
80
|
-
if (payLink.startsWith('plink_')) {
|
|
81
|
-
return <Link to={`/admin/payments/${payLink}`}>{payLink}</Link>;
|
|
82
|
-
}
|
|
83
|
-
if (payLink.startsWith('prctbl_')) {
|
|
80
|
+
if (payLink.startsWith('plink_') || payLink.startsWith('prctbl_')) {
|
|
84
81
|
return <Link to={`/admin/products/${payLink}`}>{payLink}</Link>;
|
|
85
82
|
}
|
|
86
83
|
}
|
|
@@ -85,10 +85,6 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
85
85
|
return <Alert severity="error">{t('payment.customer.changePlan.subscriptionNotFound')}</Alert>;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
if (!data.table) {
|
|
89
|
-
return <Alert severity="error">{t('payment.customer.changePlan.tableNotFound')}</Alert>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
88
|
const handleSelect = async (priceId: string) => {
|
|
93
89
|
try {
|
|
94
90
|
if (state.priceId === priceId) {
|
|
@@ -151,11 +147,7 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
151
147
|
|
|
152
148
|
// FIXME: support more proration_behavior
|
|
153
149
|
const result = await api.put(`/api/subscriptions/${id}`, { proration_behavior: 'create_prorations', items });
|
|
154
|
-
if (result.data.
|
|
155
|
-
Toast.success(t('payment.customer.changePlan.success'));
|
|
156
|
-
setState({ paid: true });
|
|
157
|
-
setTimeout(handleBack, 2000);
|
|
158
|
-
} else {
|
|
150
|
+
if (result.data.connectAction) {
|
|
159
151
|
setState({ paying: true });
|
|
160
152
|
try {
|
|
161
153
|
setState({ paying: true });
|
|
@@ -169,7 +161,10 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
169
161
|
error: t('payment.customer.changePlan.error'),
|
|
170
162
|
confirm: '',
|
|
171
163
|
} as any,
|
|
172
|
-
extraParams: {
|
|
164
|
+
extraParams: {
|
|
165
|
+
invoiceId: result.data.pending_update?.updates?.latest_invoice_id,
|
|
166
|
+
subscriptionId: result.data.id,
|
|
167
|
+
},
|
|
173
168
|
onSuccess: () => {
|
|
174
169
|
setState({ paid: true, paying: false });
|
|
175
170
|
setTimeout(() => {
|
|
@@ -191,6 +186,10 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
191
186
|
} finally {
|
|
192
187
|
setState({ paying: false });
|
|
193
188
|
}
|
|
189
|
+
} else {
|
|
190
|
+
Toast.success(t('payment.customer.changePlan.success'));
|
|
191
|
+
setState({ paid: true });
|
|
192
|
+
setTimeout(handleBack, 2000);
|
|
194
193
|
}
|
|
195
194
|
} catch (err) {
|
|
196
195
|
console.error(err);
|
|
@@ -236,7 +235,7 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
236
235
|
<Stack
|
|
237
236
|
direction="row"
|
|
238
237
|
alignItems="center"
|
|
239
|
-
sx={{ fontWeight: 'normal', mt: '16px' }}
|
|
238
|
+
sx={{ fontWeight: 'normal', mt: '16px', cursor: 'pointer' }}
|
|
240
239
|
onClick={() => goBackOrFallback(`/customer/subscription/${data.subscription.id}`)}>
|
|
241
240
|
<ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
|
|
242
241
|
<SubscriptionDescription subscription={data.subscription} variant="h5" />
|
|
@@ -246,6 +245,7 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
246
245
|
<SectionHeader title={t('payment.customer.changePlan.config')} />
|
|
247
246
|
<PricingTable mode="select" alignItems="left" interval={interval} table={table} onSelect={handleSelect} />
|
|
248
247
|
</Stack>
|
|
248
|
+
{!data.table && <Alert severity="error">{t('payment.customer.changePlan.tableNotFound')}</Alert>}
|
|
249
249
|
{state.priceId && state.total && state.setup && (
|
|
250
250
|
<Stack direction="column" spacing={3} sx={{ maxWidth: 640 }}>
|
|
251
251
|
<SectionHeader title={t('payment.customer.changePlan.confirm')} />
|