payment-kit 1.19.0 → 1.19.2
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/crons/index.ts +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/util.ts +3 -1
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +728 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/customers.ts +34 -5
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +20 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/routes/webhook-endpoints.ts +0 -3
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +4 -4
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/collapse.tsx +11 -1
- package/src/components/conditional-section.tsx +87 -0
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +246 -0
- package/src/components/customer/form.tsx +7 -3
- package/src/components/invoice/list.tsx +19 -1
- package/src/components/metadata/form.tsx +287 -91
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/payment-currency/form.tsx +2 -0
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payment-link/item.tsx +2 -2
- package/src/components/payment-link/preview.tsx +1 -1
- package/src/components/payment-link/product-select.tsx +52 -12
- package/src/components/payment-method/arcblock.tsx +2 -0
- package/src/components/payment-method/base.tsx +2 -0
- package/src/components/payment-method/bitcoin.tsx +2 -0
- package/src/components/payment-method/ethereum.tsx +2 -0
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/payouts/portal/list.tsx +6 -11
- package/src/components/price/currency-select.tsx +56 -32
- package/src/components/price/form.tsx +912 -407
- package/src/components/pricing-table/preview.tsx +1 -1
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +7 -4
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +17 -7
- package/src/components/product/form.tsx +100 -90
- package/src/components/refund/list.tsx +19 -1
- package/src/components/section/header.tsx +5 -18
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/components/subscription/metrics.tsx +37 -5
- package/src/components/subscription/portal/actions.tsx +2 -1
- package/src/contexts/products.tsx +26 -9
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +389 -5
- package/src/locales/zh.tsx +368 -1
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +14 -10
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/payments/intents/detail.tsx +1 -1
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
- package/src/pages/admin/payments/refunds/detail.tsx +1 -1
- package/src/pages/admin/products/index.tsx +3 -2
- package/src/pages/admin/products/links/detail.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +16 -4
- package/src/pages/admin/products/prices/detail.tsx +30 -3
- package/src/pages/admin/products/prices/list.tsx +8 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
- package/src/pages/admin/products/products/create.tsx +233 -57
- package/src/pages/admin/products/products/detail.tsx +2 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +44 -9
- package/src/pages/customer/recharge/account.tsx +5 -5
- package/src/pages/customer/subscription/change-payment.tsx +4 -2
- package/src/pages/customer/subscription/detail.tsx +48 -14
- package/src/pages/customer/subscription/embed.tsx +1 -1
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
getOneTimeLineItems,
|
|
39
39
|
getCheckoutSessionSubscriptionIds,
|
|
40
40
|
getSubscriptionLineItems,
|
|
41
|
+
isCreditMeteredLineItems,
|
|
41
42
|
} from '../libs/session';
|
|
42
43
|
import { getDaysUntilCancel, getDaysUntilDue, getSubscriptionTrialSetup } from '../libs/subscription';
|
|
43
44
|
import {
|
|
@@ -78,6 +79,7 @@ import { paymentQueue } from '../queues/payment';
|
|
|
78
79
|
import { invoiceQueue } from '../queues/invoice';
|
|
79
80
|
import { ensureInvoiceForCheckout, ensureInvoicesForSubscriptions } from './connect/shared';
|
|
80
81
|
import {
|
|
82
|
+
isCreditGrantSufficientForPayment,
|
|
81
83
|
isCreditSufficientForPayment,
|
|
82
84
|
isDelegationSufficientForPayment,
|
|
83
85
|
SufficientForPaymentResult,
|
|
@@ -616,6 +618,8 @@ async function processSubscriptionFastCheckout({
|
|
|
616
618
|
success: boolean;
|
|
617
619
|
invoices?: Invoice[];
|
|
618
620
|
message?: string;
|
|
621
|
+
token?: { address: string; balance: string };
|
|
622
|
+
amount?: string;
|
|
619
623
|
}> {
|
|
620
624
|
try {
|
|
621
625
|
const primarySubscription = subscriptions.find((x) => x.metadata?.is_primary_subscription) || subscriptions[0];
|
|
@@ -629,12 +633,17 @@ async function processSubscriptionFastCheckout({
|
|
|
629
633
|
.reduce((sum: BN, amt: string) => sum.add(new BN(amt)), new BN('0'))
|
|
630
634
|
.toString();
|
|
631
635
|
|
|
636
|
+
let validAmount = totalAmount;
|
|
637
|
+
if (totalAmount === '0' && paymentCurrency.isCredit()) {
|
|
638
|
+
validAmount = '1';
|
|
639
|
+
}
|
|
632
640
|
const delegationCheck = await isDelegationSufficientForPayment({
|
|
633
641
|
paymentMethod,
|
|
634
642
|
paymentCurrency,
|
|
635
643
|
userDid: customer.did,
|
|
636
|
-
amount:
|
|
644
|
+
amount: validAmount,
|
|
637
645
|
delegatorAmounts: subscriptionAmounts,
|
|
646
|
+
lineItems,
|
|
638
647
|
});
|
|
639
648
|
|
|
640
649
|
if (!delegationCheck.sufficient) {
|
|
@@ -646,6 +655,8 @@ async function processSubscriptionFastCheckout({
|
|
|
646
655
|
return {
|
|
647
656
|
success: false,
|
|
648
657
|
message: `Insufficient delegation or insufficient balance: ${delegationCheck.reason}`,
|
|
658
|
+
token: delegationCheck.token,
|
|
659
|
+
amount: totalAmount,
|
|
649
660
|
};
|
|
650
661
|
}
|
|
651
662
|
|
|
@@ -658,6 +669,33 @@ async function processSubscriptionFastCheckout({
|
|
|
658
669
|
if (executePayment) {
|
|
659
670
|
// Update payment settings for all subscriptions
|
|
660
671
|
await Promise.all(subscriptions.map((sub) => sub.update({ payment_settings: paymentSettings })));
|
|
672
|
+
if (paymentCurrency.isCredit()) {
|
|
673
|
+
// skip invoice creation for credit subscriptions
|
|
674
|
+
checkoutSession.update({
|
|
675
|
+
status: 'complete',
|
|
676
|
+
payment_status: 'paid',
|
|
677
|
+
});
|
|
678
|
+
await Promise.all(
|
|
679
|
+
subscriptions.map(async (sub) => {
|
|
680
|
+
await sub.update({
|
|
681
|
+
payment_settings: paymentSettings,
|
|
682
|
+
status: sub.trial_end ? 'trialing' : 'active',
|
|
683
|
+
payment_details: {
|
|
684
|
+
[paymentMethod.type]: {
|
|
685
|
+
type: 'credit',
|
|
686
|
+
payer: customer.did,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
addSubscriptionJob(sub, 'cycle', false, sub.trial_end);
|
|
691
|
+
})
|
|
692
|
+
);
|
|
693
|
+
return {
|
|
694
|
+
success: true,
|
|
695
|
+
invoices: [],
|
|
696
|
+
token: delegationCheck.token,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
661
699
|
|
|
662
700
|
// Create invoices for all subscriptions
|
|
663
701
|
const { invoices } = await ensureInvoicesForSubscriptions({
|
|
@@ -686,11 +724,15 @@ async function processSubscriptionFastCheckout({
|
|
|
686
724
|
return {
|
|
687
725
|
success: true,
|
|
688
726
|
invoices,
|
|
727
|
+
token: delegationCheck.token,
|
|
728
|
+
amount: totalAmount,
|
|
689
729
|
};
|
|
690
730
|
}
|
|
691
731
|
return {
|
|
692
732
|
success: true,
|
|
693
733
|
invoices: [],
|
|
734
|
+
token: delegationCheck.token,
|
|
735
|
+
amount: totalAmount,
|
|
694
736
|
};
|
|
695
737
|
} catch (error) {
|
|
696
738
|
logger.error('Error processing subscription fast checkout', {
|
|
@@ -701,6 +743,8 @@ async function processSubscriptionFastCheckout({
|
|
|
701
743
|
return {
|
|
702
744
|
success: false,
|
|
703
745
|
message: error.message,
|
|
746
|
+
token: { address: paymentCurrency.id, balance: '0' },
|
|
747
|
+
amount: '0',
|
|
704
748
|
};
|
|
705
749
|
}
|
|
706
750
|
}
|
|
@@ -754,7 +798,9 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
|
|
|
754
798
|
}
|
|
755
799
|
|
|
756
800
|
// Settings priority: PaymentLink.subscription_data > req.query > environments
|
|
757
|
-
const protectedSettings: Partial<SubscriptionData> = {
|
|
801
|
+
const protectedSettings: Partial<SubscriptionData> = {
|
|
802
|
+
no_stake: isCreditMeteredLineItems(items) ? true : link.subscription_data?.no_stake,
|
|
803
|
+
};
|
|
758
804
|
if (link.subscription_data?.min_stake_amount) {
|
|
759
805
|
protectedSettings.min_stake_amount = getMinStakeAmount(link.subscription_data);
|
|
760
806
|
}
|
|
@@ -964,6 +1010,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
964
1010
|
}
|
|
965
1011
|
|
|
966
1012
|
const checkoutSession = req.doc as CheckoutSession;
|
|
1013
|
+
logger.info('---checkoutSession---', checkoutSession.line_items);
|
|
967
1014
|
if (checkoutSession.line_items) {
|
|
968
1015
|
try {
|
|
969
1016
|
await validateInventory(checkoutSession.line_items);
|
|
@@ -1231,6 +1278,11 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1231
1278
|
let canFastPay = isPayment && canPayWithDelegation(paymentIntent?.beneficiaries || []);
|
|
1232
1279
|
let fastPayInfo = null;
|
|
1233
1280
|
let delegation: SufficientForPaymentResult | null = null;
|
|
1281
|
+
let creditSufficient = false;
|
|
1282
|
+
|
|
1283
|
+
const canFastPayForSubscription =
|
|
1284
|
+
paymentCurrency.isCredit() ||
|
|
1285
|
+
(checkoutSession.mode === 'subscription' && checkoutSession.subscription_data?.no_stake);
|
|
1234
1286
|
if (isPayment && paymentIntent && canFastPay) {
|
|
1235
1287
|
// if we can complete purchase without any wallet interaction
|
|
1236
1288
|
delegation = await isDelegationSufficientForPayment({
|
|
@@ -1246,11 +1298,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1246
1298
|
payer: customer.did,
|
|
1247
1299
|
};
|
|
1248
1300
|
}
|
|
1249
|
-
} else if (
|
|
1250
|
-
paymentMethod.type === 'arcblock' &&
|
|
1251
|
-
checkoutSession.mode === 'subscription' &&
|
|
1252
|
-
checkoutSession.subscription_data?.no_stake
|
|
1253
|
-
) {
|
|
1301
|
+
} else if (paymentMethod.type === 'arcblock' && canFastPayForSubscription) {
|
|
1254
1302
|
// if we can complete purchase without any wallet interaction
|
|
1255
1303
|
const result = await processSubscriptionFastCheckout({
|
|
1256
1304
|
checkoutSession,
|
|
@@ -1269,6 +1317,8 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1269
1317
|
logger.warn(`Fast checkout processing failed: ${result.message}`, {
|
|
1270
1318
|
checkoutSessionId: checkoutSession.id,
|
|
1271
1319
|
});
|
|
1320
|
+
|
|
1321
|
+
creditSufficient = false;
|
|
1272
1322
|
} else {
|
|
1273
1323
|
delegation = {
|
|
1274
1324
|
sufficient: true,
|
|
@@ -1276,9 +1326,13 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1276
1326
|
canFastPay = true;
|
|
1277
1327
|
fastPayInfo = {
|
|
1278
1328
|
type: 'delegation',
|
|
1279
|
-
amount:
|
|
1329
|
+
amount: result?.amount || '0',
|
|
1280
1330
|
payer: customer.did,
|
|
1331
|
+
token: result?.token,
|
|
1281
1332
|
};
|
|
1333
|
+
if (paymentCurrency.isCredit()) {
|
|
1334
|
+
creditSufficient = true;
|
|
1335
|
+
}
|
|
1282
1336
|
}
|
|
1283
1337
|
}
|
|
1284
1338
|
|
|
@@ -1411,6 +1465,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1411
1465
|
delegation: canFastPay ? delegation : null,
|
|
1412
1466
|
balance: canFastPay ? balance : null,
|
|
1413
1467
|
fastPayInfo,
|
|
1468
|
+
creditSufficient,
|
|
1414
1469
|
});
|
|
1415
1470
|
} catch (err) {
|
|
1416
1471
|
logger.error('Error submitting checkout session', {
|
|
@@ -1587,12 +1642,78 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
1587
1642
|
customer,
|
|
1588
1643
|
amount: fastCheckoutAmount,
|
|
1589
1644
|
});
|
|
1645
|
+
// Check if this is a credit payment
|
|
1646
|
+
const isCredit = paymentCurrency.isCredit();
|
|
1590
1647
|
|
|
1591
1648
|
const isPayment = checkoutSession.mode === 'payment';
|
|
1592
1649
|
let fastPaid = false;
|
|
1593
1650
|
let canFastPay = isPayment && canPayWithDelegation(paymentIntent?.beneficiaries || []);
|
|
1594
1651
|
let delegation: SufficientForPaymentResult | null = null;
|
|
1595
|
-
|
|
1652
|
+
|
|
1653
|
+
// Handle credit payment directly
|
|
1654
|
+
if (isCredit) {
|
|
1655
|
+
const result = await isCreditGrantSufficientForPayment({
|
|
1656
|
+
paymentMethod,
|
|
1657
|
+
paymentCurrency,
|
|
1658
|
+
userDid: customer.did,
|
|
1659
|
+
amount: fastCheckoutAmount,
|
|
1660
|
+
});
|
|
1661
|
+
if (!result.sufficient) {
|
|
1662
|
+
return res.status(400).json({
|
|
1663
|
+
code: 'CREDIT_INSUFFICIENT',
|
|
1664
|
+
error: result.reason,
|
|
1665
|
+
sufficient: result.sufficient,
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
fastPaid = true;
|
|
1669
|
+
// For credit payments, we use the existing subscription fast checkout flow
|
|
1670
|
+
// but skip the actual payment processing
|
|
1671
|
+
if (['setup', 'subscription'].includes(checkoutSession.mode)) {
|
|
1672
|
+
const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
|
|
1673
|
+
const subscriptions = await Subscription.findAll({ where: { id: subscriptionIds } });
|
|
1674
|
+
|
|
1675
|
+
if (checkoutSession.mode === 'setup') {
|
|
1676
|
+
const setupIntent = await SetupIntent.findByPk(checkoutSession.setup_intent_id);
|
|
1677
|
+
if (!setupIntent) {
|
|
1678
|
+
throw new Error('SetupIntent not found for checkoutSession');
|
|
1679
|
+
}
|
|
1680
|
+
await setupIntent.update({
|
|
1681
|
+
status: 'succeeded',
|
|
1682
|
+
last_setup_error: null,
|
|
1683
|
+
setup_details: {
|
|
1684
|
+
[paymentMethod.type]: {
|
|
1685
|
+
type: 'credit',
|
|
1686
|
+
payer: customer.did,
|
|
1687
|
+
},
|
|
1688
|
+
},
|
|
1689
|
+
...paymentSettings,
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
checkoutSession.update({
|
|
1693
|
+
status: 'complete',
|
|
1694
|
+
payment_status: 'paid',
|
|
1695
|
+
});
|
|
1696
|
+
await Promise.all(
|
|
1697
|
+
subscriptions.map(async (sub) => {
|
|
1698
|
+
await sub.update({
|
|
1699
|
+
payment_settings: paymentSettings,
|
|
1700
|
+
status: sub.trial_end ? 'trialing' : 'active',
|
|
1701
|
+
payment_details: {
|
|
1702
|
+
[paymentMethod.type]: {
|
|
1703
|
+
type: 'credit',
|
|
1704
|
+
payer: customer.did,
|
|
1705
|
+
},
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
addSubscriptionJob(sub, 'cycle', false, sub.trial_end);
|
|
1709
|
+
})
|
|
1710
|
+
);
|
|
1711
|
+
delegation = {
|
|
1712
|
+
sufficient: true,
|
|
1713
|
+
};
|
|
1714
|
+
canFastPay = true;
|
|
1715
|
+
}
|
|
1716
|
+
} else if (isPayment && paymentIntent && canFastPay) {
|
|
1596
1717
|
// if we can complete purchase without any wallet interaction
|
|
1597
1718
|
delegation = await isDelegationSufficientForPayment({
|
|
1598
1719
|
paymentMethod,
|
|
@@ -1764,6 +1885,33 @@ router.put('/:id/downsell', user, ensureCheckoutSessionOpen, async (req, res) =>
|
|
|
1764
1885
|
}
|
|
1765
1886
|
});
|
|
1766
1887
|
|
|
1888
|
+
// adjust quantity
|
|
1889
|
+
router.put('/:id/adjust-quantity', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
1890
|
+
try {
|
|
1891
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
1892
|
+
const { itemId, quantity } = req.body;
|
|
1893
|
+
if (!checkoutSession.line_items) {
|
|
1894
|
+
return res.status(400).json({ error: 'Line items not found' });
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
const item = checkoutSession.line_items.find((x) => x.price_id === itemId);
|
|
1898
|
+
if (!item) {
|
|
1899
|
+
return res.status(400).json({ error: 'Item not found' });
|
|
1900
|
+
}
|
|
1901
|
+
const items = cloneDeep(checkoutSession.line_items);
|
|
1902
|
+
const targetItem = items.find((x) => x.price_id === itemId);
|
|
1903
|
+
if (targetItem) {
|
|
1904
|
+
targetItem.quantity = quantity;
|
|
1905
|
+
}
|
|
1906
|
+
await validateInventory(items, true);
|
|
1907
|
+
await checkoutSession.update({ line_items: items });
|
|
1908
|
+
const lineItems = await Price.expand(checkoutSession.line_items);
|
|
1909
|
+
res.json({ ...checkoutSession.toJSON(), line_items: lineItems });
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
logger.error(err);
|
|
1912
|
+
res.status(400).json({ error: err.message });
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1767
1915
|
// eslint-disable-next-line consistent-return
|
|
1768
1916
|
router.put('/:id/expire', auth, ensureCheckoutSessionOpen, async (req, res) => {
|
|
1769
1917
|
const doc = req.doc as CheckoutSession;
|
|
@@ -1063,8 +1063,9 @@ export async function ensureSubscription(subscriptionId: string): Promise<Result
|
|
|
1063
1063
|
if (!subscription) {
|
|
1064
1064
|
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
1065
1065
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1066
|
+
|
|
1067
|
+
if (subscription.isActive() === false) {
|
|
1068
|
+
throw new Error(`Subscription ${subscriptionId} is not active`);
|
|
1068
1069
|
}
|
|
1069
1070
|
|
|
1070
1071
|
const paymentCurrencyId = subscription.currency_id;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import { fromTokenToUnit } from '@ocap/util';
|
|
4
|
+
|
|
5
|
+
import { literal, OrderItem } from 'sequelize';
|
|
6
|
+
import pick from 'lodash/pick';
|
|
7
|
+
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
8
|
+
import logger from '../libs/logger';
|
|
9
|
+
import { authenticate } from '../libs/security';
|
|
10
|
+
import { CreditGrant, Customer, PaymentCurrency, Price, Subscription } from '../store/models';
|
|
11
|
+
import { createCreditGrant } from '../libs/credit-grant';
|
|
12
|
+
import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
|
|
13
|
+
|
|
14
|
+
const router = Router();
|
|
15
|
+
const auth = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'] });
|
|
16
|
+
const authMine = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'], mine: true });
|
|
17
|
+
const authPortal = authenticate<CreditGrant>({
|
|
18
|
+
component: true,
|
|
19
|
+
roles: ['owner', 'admin'],
|
|
20
|
+
record: {
|
|
21
|
+
// @ts-ignore
|
|
22
|
+
model: CreditGrant,
|
|
23
|
+
field: 'customer_id',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const creditGrantSchema = Joi.object({
|
|
27
|
+
amount: Joi.number().required(),
|
|
28
|
+
currency_id: Joi.string().max(15).optional(),
|
|
29
|
+
customer_id: Joi.string().max(18).required(),
|
|
30
|
+
name: Joi.string().max(255).optional(),
|
|
31
|
+
category: Joi.string().valid('paid', 'promotional').required(),
|
|
32
|
+
priority: Joi.number().integer().min(0).max(100).default(50),
|
|
33
|
+
effective_at: Joi.number().integer().optional(),
|
|
34
|
+
expires_at: Joi.number().integer().optional(),
|
|
35
|
+
metadata: MetadataSchema,
|
|
36
|
+
applicability_config: Joi.object({
|
|
37
|
+
scope: Joi.object({
|
|
38
|
+
price_type: Joi.string().valid('metered').optional(),
|
|
39
|
+
prices: Joi.array().items(Joi.string()).optional(),
|
|
40
|
+
}).optional(),
|
|
41
|
+
}).optional(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const listSchema = createListParamSchema<{
|
|
45
|
+
customer_id?: string;
|
|
46
|
+
currency_id?: string;
|
|
47
|
+
status?: string;
|
|
48
|
+
livemode?: boolean;
|
|
49
|
+
q?: string;
|
|
50
|
+
}>({
|
|
51
|
+
customer_id: Joi.string().optional(),
|
|
52
|
+
currency_id: Joi.string().optional(),
|
|
53
|
+
status: Joi.string().optional(),
|
|
54
|
+
livemode: Joi.boolean().optional(),
|
|
55
|
+
q: Joi.string().optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function expandScopePrices(creditGrant: CreditGrant) {
|
|
59
|
+
const scope = creditGrant.applicability_config?.scope;
|
|
60
|
+
if (scope && scope.prices) {
|
|
61
|
+
const expandedItems = await Price.expand(
|
|
62
|
+
scope.prices.map((x) => ({ id: x, price_id: x, quantity: 1 })),
|
|
63
|
+
{ product: true }
|
|
64
|
+
);
|
|
65
|
+
return expandedItems;
|
|
66
|
+
}
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
router.get('/', authMine, async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
|
|
73
|
+
const where = getWhereFromKvQuery(query.q);
|
|
74
|
+
if (query.customer_id) {
|
|
75
|
+
where.customer_id = query.customer_id;
|
|
76
|
+
}
|
|
77
|
+
if (query.currency_id) {
|
|
78
|
+
where.currency_id = query.currency_id;
|
|
79
|
+
}
|
|
80
|
+
if (query.status) {
|
|
81
|
+
where.status = typeof query.status === 'string' ? query.status.split(',') : query.status;
|
|
82
|
+
}
|
|
83
|
+
if (typeof query.livemode === 'boolean') {
|
|
84
|
+
where.livemode = query.livemode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const order: OrderItem[] = getOrder(req.query);
|
|
88
|
+
// 默认granted 、pending、depleted 排序
|
|
89
|
+
order.unshift([literal("CASE status WHEN 'granted' THEN 1 WHEN 'pending' THEN 2 ELSE 3 END"), 'ASC']);
|
|
90
|
+
|
|
91
|
+
const { rows: list, count } = await CreditGrant.findAndCountAll({
|
|
92
|
+
where,
|
|
93
|
+
order,
|
|
94
|
+
offset: (page - 1) * pageSize,
|
|
95
|
+
limit: pageSize,
|
|
96
|
+
include: [
|
|
97
|
+
{ model: Customer, as: 'customer' },
|
|
98
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
res.json({ count, list, paging: { page, pageSize } });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.error('Error listing credit grants', err);
|
|
105
|
+
res.status(400).json({ error: err.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
router.get('/summary', authMine, async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const customerId = req.query.customer_id;
|
|
112
|
+
if (!customerId) {
|
|
113
|
+
return res.status(400).json({ error: 'customer_id is required' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const customer = await Customer.findByPkOrDid(customerId as string);
|
|
117
|
+
if (!customer) {
|
|
118
|
+
return res.status(404).json({ error: `Customer ${customerId} not found` });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { subscription_id: subscriptionId } = req.query;
|
|
122
|
+
if (subscriptionId && typeof subscriptionId !== 'string') {
|
|
123
|
+
return res.status(400).json({ error: 'subscription_id must be a string' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let priceIds: string[] = [];
|
|
127
|
+
if (subscriptionId) {
|
|
128
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
129
|
+
if (!subscription) {
|
|
130
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
131
|
+
}
|
|
132
|
+
priceIds = await getMeterPriceIdsFromSubscription(subscription);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await CreditGrant.getEffectiveCreditSummary({
|
|
136
|
+
customerId: customer.id,
|
|
137
|
+
priceIds,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return res.json(result);
|
|
141
|
+
} catch (err: any) {
|
|
142
|
+
logger.error('get credit balance failed', { error: err.message, customerId: req.params.customer_id });
|
|
143
|
+
return res.status(400).json({ error: err.message });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
router.get('/:id', authPortal, async (req, res) => {
|
|
148
|
+
const creditGrant = await CreditGrant.findByPk(req.params.id, {
|
|
149
|
+
include: [
|
|
150
|
+
{ model: Customer, as: 'customer' },
|
|
151
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
if (!creditGrant) {
|
|
155
|
+
return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
|
|
156
|
+
}
|
|
157
|
+
const expandedPrices = await expandScopePrices(creditGrant);
|
|
158
|
+
return res.json({
|
|
159
|
+
...creditGrant.toJSON(),
|
|
160
|
+
items: expandedPrices,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
router.post('/', auth, async (req, res) => {
|
|
165
|
+
try {
|
|
166
|
+
const { error } = creditGrantSchema.validate(req.body);
|
|
167
|
+
if (error) {
|
|
168
|
+
return res.status(400).json({ error: `Credit grant create request invalid: ${error.message}` });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 获取币种信息用于金额转换
|
|
172
|
+
const currencyId = req.body.currency_id;
|
|
173
|
+
if (!currencyId) {
|
|
174
|
+
return res.status(400).json({ error: 'currency_id is required' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
178
|
+
if (!paymentCurrency) {
|
|
179
|
+
return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const customer = await Customer.findByPkOrDid(req.body.customer_id);
|
|
183
|
+
if (!customer) {
|
|
184
|
+
return res.status(404).json({ error: `Customer ${req.body.customer_id} not found` });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const unitAmount = fromTokenToUnit(req.body.amount, paymentCurrency.decimal).toString();
|
|
188
|
+
let applicabilityConfig = req.body.applicability_config;
|
|
189
|
+
if (!req.body.applicability_config || !req.body.applicability_config.scope?.prices) {
|
|
190
|
+
applicabilityConfig = {
|
|
191
|
+
scope: {
|
|
192
|
+
price_type: 'metered',
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const creditGrant = await createCreditGrant({
|
|
198
|
+
amount: unitAmount,
|
|
199
|
+
currency_id: currencyId,
|
|
200
|
+
customer_id: req.body.customer_id,
|
|
201
|
+
name: req.body.name,
|
|
202
|
+
category: req.body.category,
|
|
203
|
+
priority: req.body.priority,
|
|
204
|
+
effective_at: req.body.effective_at,
|
|
205
|
+
expires_at: req.body.expires_at,
|
|
206
|
+
applicability_config: applicabilityConfig,
|
|
207
|
+
metadata: req.body.metadata,
|
|
208
|
+
livemode: req.livemode,
|
|
209
|
+
created_via: req.user?.via || 'api',
|
|
210
|
+
created_by: req.user?.did,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return res.json({
|
|
214
|
+
...creditGrant.toJSON(),
|
|
215
|
+
customer,
|
|
216
|
+
paymentCurrency,
|
|
217
|
+
});
|
|
218
|
+
} catch (err: any) {
|
|
219
|
+
logger.error('create credit grant failed', { error: err.message, request: req.body });
|
|
220
|
+
return res.status(400).json({ error: err.message });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const updateMetadataSchema = Joi.object({
|
|
225
|
+
metadata: MetadataSchema,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
router.put('/:id', auth, async (req, res) => {
|
|
229
|
+
const creditGrant = await CreditGrant.findByPk(req.params.id);
|
|
230
|
+
if (!creditGrant) {
|
|
231
|
+
return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
|
|
232
|
+
}
|
|
233
|
+
const { error } = updateMetadataSchema.validate(pick(req.body, 'metadata'));
|
|
234
|
+
if (error) {
|
|
235
|
+
return res.status(400).json({ error: `Credit grant update request invalid: ${error.message}` });
|
|
236
|
+
}
|
|
237
|
+
await creditGrant.update({ metadata: req.body.metadata });
|
|
238
|
+
return res.json({ success: true });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export default router;
|