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
|
@@ -20,6 +20,7 @@ import { createIdGenerator, formatMetadata } from '../../libs/util';
|
|
|
20
20
|
import { sequelize } from '../sequelize';
|
|
21
21
|
import type { TPaymentCurrency } from './payment-currency';
|
|
22
22
|
import type { CustomUnitAmount, LineItem, PriceCurrency, PriceRecurring, PriceTier, TransformQuantity } from './types';
|
|
23
|
+
import { Meter, type TMeter } from './meter';
|
|
23
24
|
|
|
24
25
|
export const nextPriceId = createIdGenerator('price', 24);
|
|
25
26
|
|
|
@@ -32,6 +33,7 @@ type TPriceExpanded = TPrice & {
|
|
|
32
33
|
upsells_to: TPriceExpanded;
|
|
33
34
|
upsells_to_id: string;
|
|
34
35
|
};
|
|
36
|
+
meter?: TMeter;
|
|
35
37
|
};
|
|
36
38
|
type TLineItemExpanded = LineItem & { price: TPriceExpanded; upsell_price: TPriceExpanded };
|
|
37
39
|
|
|
@@ -395,6 +397,10 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
|
|
|
395
397
|
x.upsell.upsells_to = to;
|
|
396
398
|
}
|
|
397
399
|
}
|
|
400
|
+
if (x.recurring?.meter_id) {
|
|
401
|
+
// @ts-ignore
|
|
402
|
+
x.meter = await Meter.findByPk(x.recurring?.meter_id);
|
|
403
|
+
}
|
|
398
404
|
})
|
|
399
405
|
);
|
|
400
406
|
}
|
|
@@ -20,7 +20,7 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
|
|
|
20
20
|
declare livemode: boolean;
|
|
21
21
|
declare locked: CreationOptional<boolean>;
|
|
22
22
|
|
|
23
|
-
declare type: LiteralUnion<'service' | 'good', string>;
|
|
23
|
+
declare type: LiteralUnion<'service' | 'good' | 'credit', string>;
|
|
24
24
|
|
|
25
25
|
// The product’s name, meant to be displayable to the customer.
|
|
26
26
|
declare name: string;
|
|
@@ -79,7 +79,7 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
|
|
|
79
79
|
defaultValue: false,
|
|
80
80
|
},
|
|
81
81
|
type: {
|
|
82
|
-
type: DataTypes.ENUM('service', 'good'),
|
|
82
|
+
type: DataTypes.ENUM('service', 'good', 'credit'),
|
|
83
83
|
},
|
|
84
84
|
name: {
|
|
85
85
|
type: DataTypes.STRING(512),
|
|
@@ -410,6 +410,30 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
|
|
|
410
410
|
return this.isActive() && (!!this.cancel_at_period_end || !!this.cancel_at);
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
public async isConsumesCredit(): Promise<boolean> {
|
|
414
|
+
// @ts-ignore
|
|
415
|
+
const { SubscriptionItem, Price } = this.sequelize.models;
|
|
416
|
+
if (!SubscriptionItem || !Price) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 查询订阅的所有价格项
|
|
421
|
+
const items = await SubscriptionItem.findAll({
|
|
422
|
+
where: { subscription_id: this.id },
|
|
423
|
+
include: [
|
|
424
|
+
{
|
|
425
|
+
model: Price,
|
|
426
|
+
as: 'price',
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return items.some((item: any) => {
|
|
432
|
+
const recurring = item.price?.recurring;
|
|
433
|
+
return recurring && recurring.usage_type === 'metered' && recurring.meter_id;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
413
437
|
public async start() {
|
|
414
438
|
if (this.status === 'active') {
|
|
415
439
|
logger.warn(`subscription already active: ${this.id}`);
|
|
@@ -46,6 +46,7 @@ export type PriceRecurring = {
|
|
|
46
46
|
interval_count: number;
|
|
47
47
|
aggregate_usage?: LiteralUnion<'sum' | 'last_during_period' | 'max' | 'last_ever', string>;
|
|
48
48
|
usage_type?: LiteralUnion<'licensed' | 'metered', string>;
|
|
49
|
+
meter_id?: string;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export type PriceCurrency = {
|
|
@@ -325,7 +326,7 @@ export type PaymentDetails = {
|
|
|
325
326
|
arcblock?: {
|
|
326
327
|
tx_hash: string;
|
|
327
328
|
payer: string;
|
|
328
|
-
type?: LiteralUnion<'slash' | 'transfer' | 'delegate' | 'stake_return', string>;
|
|
329
|
+
type?: LiteralUnion<'slash' | 'transfer' | 'delegate' | 'stake_return' | 'credit', string>;
|
|
329
330
|
receiver?: string;
|
|
330
331
|
staking?: {
|
|
331
332
|
tx_hash: string;
|
|
@@ -722,7 +723,11 @@ export type EventType = LiteralUnion<
|
|
|
722
723
|
| 'transfer.reversed'
|
|
723
724
|
| 'transfer.updated'
|
|
724
725
|
| 'billing.discrepancy'
|
|
725
|
-
| 'usage.report.empty'
|
|
726
|
+
| 'usage.report.empty'
|
|
727
|
+
| 'customer.credit.insufficient'
|
|
728
|
+
| 'customer.credit_grant.granted'
|
|
729
|
+
| 'customer.credit_grant.low_balance'
|
|
730
|
+
| 'customer.credit_grant.depleted',
|
|
726
731
|
string
|
|
727
732
|
>;
|
|
728
733
|
|
|
@@ -751,3 +756,24 @@ export type NotificationSetting = {
|
|
|
751
756
|
exclude_events?: EventType[];
|
|
752
757
|
include_events?: EventType[];
|
|
753
758
|
};
|
|
759
|
+
|
|
760
|
+
export type CreditGrantApplicabilityConfig = {
|
|
761
|
+
scope: {
|
|
762
|
+
prices?: string[]; // 可选,指定适用的价格ID列表
|
|
763
|
+
price_type?: 'metered'; // 可选,按价格类型适用, 目前只支持metered
|
|
764
|
+
};
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
export type MeterEventStatus =
|
|
768
|
+
| 'pending'
|
|
769
|
+
| 'processing'
|
|
770
|
+
| 'requires_action'
|
|
771
|
+
| 'requires_capture'
|
|
772
|
+
| 'completed'
|
|
773
|
+
| 'canceled';
|
|
774
|
+
|
|
775
|
+
export type MeterEventPayload = {
|
|
776
|
+
customer_id: string;
|
|
777
|
+
value: string;
|
|
778
|
+
subscription_id?: string;
|
|
779
|
+
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getSubscriptionStakeAmountSetup,
|
|
12
12
|
checkUsageReportEmpty,
|
|
13
13
|
calculateRecommendedRechargeAmount,
|
|
14
|
+
getSubscriptionCycleSetup,
|
|
14
15
|
} from '../../src/libs/subscription';
|
|
15
16
|
import { PaymentMethod, Subscription, SubscriptionItem, UsageRecord, Price } from '../../src/store/models';
|
|
16
17
|
|
|
@@ -766,3 +767,55 @@ describe('calculateRecommendedRechargeAmount', () => {
|
|
|
766
767
|
expect(result.amount).toBe('300');
|
|
767
768
|
});
|
|
768
769
|
});
|
|
770
|
+
|
|
771
|
+
describe('getSubscriptionCycleSetup', () => {
|
|
772
|
+
const mockRecurring = {
|
|
773
|
+
interval: 'day',
|
|
774
|
+
interval_count: 1,
|
|
775
|
+
usage_type: 'licensed',
|
|
776
|
+
} as any;
|
|
777
|
+
|
|
778
|
+
it('should return normal setup without catchUp option', () => {
|
|
779
|
+
const previousPeriodEnd = dayjs().subtract(1, 'day').unix();
|
|
780
|
+
const result = getSubscriptionCycleSetup(mockRecurring, previousPeriodEnd);
|
|
781
|
+
|
|
782
|
+
expect(result.missedPeriods).toBe(0);
|
|
783
|
+
expect(result.period.start).toBe(previousPeriodEnd);
|
|
784
|
+
expect(result.recovery).toBeUndefined();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('should detect and calculate missed periods with catchUp enabled', () => {
|
|
788
|
+
const previousPeriodEnd = dayjs().subtract(5, 'days').unix(); // 5 days ago
|
|
789
|
+
const result = getSubscriptionCycleSetup(mockRecurring, previousPeriodEnd, {
|
|
790
|
+
catchUp: true,
|
|
791
|
+
maxMissedPeriods: 10,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
expect(result.missedPeriods).toBeGreaterThan(0);
|
|
795
|
+
expect(result.recovery).toBeDefined();
|
|
796
|
+
expect(result.recovery?.originalPeriodEnd).toBe(previousPeriodEnd);
|
|
797
|
+
expect(result.recovery?.periodsSkipped).toBeGreaterThan(0);
|
|
798
|
+
expect(result.period.start).toBeGreaterThan(previousPeriodEnd);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('should limit missed periods to maxMissedPeriods', () => {
|
|
802
|
+
const previousPeriodEnd = dayjs().subtract(100, 'days').unix(); // Very old
|
|
803
|
+
const result = getSubscriptionCycleSetup(mockRecurring, previousPeriodEnd, {
|
|
804
|
+
catchUp: true,
|
|
805
|
+
maxMissedPeriods: 10,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
expect(result.missedPeriods).toBe(10);
|
|
809
|
+
expect(result.recovery?.wasLimited).toBe(true);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('should handle future period end without catchUp', () => {
|
|
813
|
+
const futurePeriodEnd = dayjs().add(1, 'day').unix();
|
|
814
|
+
const result = getSubscriptionCycleSetup(mockRecurring, futurePeriodEnd, {
|
|
815
|
+
catchUp: true,
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(result.missedPeriods).toBe(0);
|
|
819
|
+
expect(result.period.start).toBe(futurePeriodEnd);
|
|
820
|
+
});
|
|
821
|
+
});
|
package/blocklet.yml
CHANGED
|
@@ -14,7 +14,7 @@ repository:
|
|
|
14
14
|
type: git
|
|
15
15
|
url: git+https://github.com/blocklet/payment-kit.git
|
|
16
16
|
specVersion: 1.2.8
|
|
17
|
-
version: 1.19.
|
|
17
|
+
version: 1.19.2
|
|
18
18
|
logo: logo.png
|
|
19
19
|
files:
|
|
20
20
|
- dist
|
|
@@ -169,3 +169,11 @@ events:
|
|
|
169
169
|
description: Refund has been successfully processed and completed
|
|
170
170
|
- type: manual.notification
|
|
171
171
|
description: Application will send notification to user manually
|
|
172
|
+
- type: customer.credit_grant.granted
|
|
173
|
+
description: Credit grant has been successfully granted
|
|
174
|
+
- type: customer.credit_grant.low_balance
|
|
175
|
+
description: Credit grant has low balance
|
|
176
|
+
- type: customer.credit_grant.depleted
|
|
177
|
+
description: Credit grant has been depleted
|
|
178
|
+
- type: customer.credit.insufficient
|
|
179
|
+
description: Customer has insufficient credit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.2",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@blocklet/did-space-js": "^1.0.62",
|
|
55
55
|
"@blocklet/js-sdk": "^1.16.44",
|
|
56
56
|
"@blocklet/logger": "^1.16.44",
|
|
57
|
-
"@blocklet/payment-react": "1.19.
|
|
57
|
+
"@blocklet/payment-react": "1.19.2",
|
|
58
58
|
"@blocklet/sdk": "^1.16.44",
|
|
59
59
|
"@blocklet/ui-react": "^3.0.1",
|
|
60
60
|
"@blocklet/uploader": "^0.1.97",
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
"devDependencies": {
|
|
123
123
|
"@abtnode/types": "^1.16.44",
|
|
124
124
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
125
|
-
"@blocklet/payment-types": "1.19.
|
|
125
|
+
"@blocklet/payment-types": "1.19.2",
|
|
126
126
|
"@types/cookie-parser": "^1.4.9",
|
|
127
127
|
"@types/cors": "^2.8.19",
|
|
128
128
|
"@types/debug": "^4.1.12",
|
|
@@ -168,5 +168,5 @@
|
|
|
168
168
|
"parser": "typescript"
|
|
169
169
|
}
|
|
170
170
|
},
|
|
171
|
-
"gitHead": "
|
|
171
|
+
"gitHead": "741c897204afc412721a942201516932bff59235"
|
|
172
172
|
}
|
package/scripts/sdk.js
CHANGED
|
@@ -548,17 +548,249 @@ const subscriptionModule = {
|
|
|
548
548
|
},
|
|
549
549
|
};
|
|
550
550
|
|
|
551
|
+
const meterModule = {
|
|
552
|
+
async createMeter() {
|
|
553
|
+
const meter = await payment.meters.create({
|
|
554
|
+
name: 'API Calls',
|
|
555
|
+
event_name: 'api_calls',
|
|
556
|
+
aggregation_method: 'sum',
|
|
557
|
+
unit: 'Token',
|
|
558
|
+
description: 'Track API usage',
|
|
559
|
+
component_did: 'zNKtcX5QyTxfM51osn5W8GMWvzabBREW2abA',
|
|
560
|
+
created_via: 'api',
|
|
561
|
+
});
|
|
562
|
+
console.log('Created meter:', meter);
|
|
563
|
+
return meter;
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
// 创建基于 Meter 的订阅产品和价格
|
|
567
|
+
async createMeteredSubscription() {
|
|
568
|
+
let meter;
|
|
569
|
+
try {
|
|
570
|
+
meter = await payment.meters.retrieve('api_calls');
|
|
571
|
+
if (!meter) {
|
|
572
|
+
meter = await this.createMeter();
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
meter = await this.createMeter();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 创建订阅产品
|
|
579
|
+
const subscriptionProduct = await payment.products.create({
|
|
580
|
+
name: 'API Call Service',
|
|
581
|
+
description: 'API Call Service',
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// 创建基于 meter 的订阅价格
|
|
585
|
+
const subscriptionPrice = await payment.prices.create({
|
|
586
|
+
product_id: subscriptionProduct.id,
|
|
587
|
+
currency_id: meter.currency_id,
|
|
588
|
+
unit_amount: '1',
|
|
589
|
+
type: 'recurring',
|
|
590
|
+
recurring: {
|
|
591
|
+
interval: 'month',
|
|
592
|
+
interval_count: 1,
|
|
593
|
+
usage_type: 'metered',
|
|
594
|
+
meter_id: meter.id,
|
|
595
|
+
aggregate_usage: 'sum',
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
console.log('subscriptionPrice', subscriptionPrice);
|
|
600
|
+
// 创建 checkout session
|
|
601
|
+
const subscriptionCheckout = await payment.checkout.sessions.create({
|
|
602
|
+
success_url: 'https://audiobook.com/success?session_id={CHECKOUT_SESSION_ID}',
|
|
603
|
+
cancel_url: 'https://audiobook.com/cancel',
|
|
604
|
+
mode: 'subscription',
|
|
605
|
+
line_items: [
|
|
606
|
+
{
|
|
607
|
+
price_id: subscriptionPrice.id,
|
|
608
|
+
quantity: 1,
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
console.log('Metered subscription checkout:', subscriptionCheckout);
|
|
614
|
+
return { meter, subscriptionProduct, subscriptionPrice, subscriptionCheckout };
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// 新增 Credit 相关模块
|
|
619
|
+
const creditModule = {
|
|
620
|
+
// 创建 Credit 产品
|
|
621
|
+
async createCreditProduct(meter) {
|
|
622
|
+
const creditProduct = await payment.products.create({
|
|
623
|
+
name: 'API Calls Credit',
|
|
624
|
+
description: 'API Call Credit, we can ...',
|
|
625
|
+
type: 'credit',
|
|
626
|
+
prices: [
|
|
627
|
+
{
|
|
628
|
+
type: 'one_time',
|
|
629
|
+
unit_amount: '0.01',
|
|
630
|
+
currency_id: 'pc_ByBkyhRQmedm',
|
|
631
|
+
currency_options: [
|
|
632
|
+
{ currency_id: 'pc_ByBkyhRQmedm', unit_amount: '0.01' },
|
|
633
|
+
{ currency_id: 'pc_Dp4lY5ejkALH', unit_amount: '0.01' },
|
|
634
|
+
],
|
|
635
|
+
lookup_key: 'api_call_credit_per_unit',
|
|
636
|
+
nickname: 'Per Unit Credit For API Call',
|
|
637
|
+
metadata: {
|
|
638
|
+
credit_config: {
|
|
639
|
+
priority: 50,
|
|
640
|
+
valid_duration_value: 0,
|
|
641
|
+
valid_duration_unit: 'days',
|
|
642
|
+
currency_id: meter.currency_id,
|
|
643
|
+
credit_amount: '1',
|
|
644
|
+
},
|
|
645
|
+
meter_id: meter.id,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
type: 'one_time',
|
|
650
|
+
unit_amount: '8',
|
|
651
|
+
currency_id: 'pc_ByBkyhRQmedm',
|
|
652
|
+
lookup_key: 'api_calls_1000_pack',
|
|
653
|
+
nickname: '1000 Credits Pack For API Call',
|
|
654
|
+
currency_options: [
|
|
655
|
+
{ currency_id: 'pc_ByBkyhRQmedm', unit_amount: '8' },
|
|
656
|
+
{ currency_id: 'pc_Dp4lY5ejkALH', unit_amount: '8' },
|
|
657
|
+
],
|
|
658
|
+
metadata: {
|
|
659
|
+
credit_config: {
|
|
660
|
+
priority: 50,
|
|
661
|
+
valid_duration_value: 0,
|
|
662
|
+
valid_duration_unit: 'days',
|
|
663
|
+
currency_id: meter.currency_id,
|
|
664
|
+
credit_amount: '1000',
|
|
665
|
+
},
|
|
666
|
+
meter_id: meter.id,
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
});
|
|
671
|
+
console.log('Created credit product:', creditProduct.id);
|
|
672
|
+
console.log(
|
|
673
|
+
'Available prices:',
|
|
674
|
+
creditProduct.prices.map((p) => p.lookup_key)
|
|
675
|
+
);
|
|
676
|
+
return creditProduct;
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
// 创建支付链接
|
|
680
|
+
async createPaymentLinks() {
|
|
681
|
+
const perUnitPrice = await payment.prices.retrieve('api_call_credit_per_unit');
|
|
682
|
+
console.log('perUnitPrice', perUnitPrice);
|
|
683
|
+
// 1. 按分钟购买
|
|
684
|
+
const flexiblePaymentLink = await payment.paymentLinks.create({
|
|
685
|
+
name: 'Per Unit Credit Link',
|
|
686
|
+
currency_id: perUnitPrice.currency_id,
|
|
687
|
+
line_items: [
|
|
688
|
+
{
|
|
689
|
+
price_id: perUnitPrice.id,
|
|
690
|
+
quantity: 100,
|
|
691
|
+
adjustable_quantity: {
|
|
692
|
+
enabled: true,
|
|
693
|
+
minimum: 10,
|
|
694
|
+
maximum: 10000,
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
],
|
|
698
|
+
metadata: {
|
|
699
|
+
credit_purchase: 'true',
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const packPrice = await payment.prices.retrieve('api_calls_1000_pack');
|
|
704
|
+
console.log('packPrice', packPrice);
|
|
705
|
+
// 2. 固定套餐包
|
|
706
|
+
const packagePaymentLink = await payment.paymentLinks.create({
|
|
707
|
+
name: 'Package Credit Link',
|
|
708
|
+
currency_id: packPrice.currency_id,
|
|
709
|
+
line_items: [
|
|
710
|
+
{
|
|
711
|
+
price_id: packPrice.id,
|
|
712
|
+
quantity: 1,
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
metadata: {
|
|
716
|
+
credit_purchase: 'true',
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
console.log('Flexible payment link:', flexiblePaymentLink);
|
|
721
|
+
console.log('Package payment link:', packagePaymentLink);
|
|
722
|
+
return { flexiblePaymentLink, packagePaymentLink };
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
// 创建/赠送 Credit Grant
|
|
726
|
+
async createCreditGrant(meter) {
|
|
727
|
+
// 方式2: 通用 Credit Grant
|
|
728
|
+
const universalCredit = await payment.creditGrants.create({
|
|
729
|
+
customer_id: 'cus_xga1PZSmiDZfz0',
|
|
730
|
+
amount: 50,
|
|
731
|
+
currency_id: meter.currency_id,
|
|
732
|
+
applicability_config: {
|
|
733
|
+
scope: {
|
|
734
|
+
price_type: 'metered',
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
category: 'promotional',
|
|
738
|
+
name: '听书时长充值',
|
|
739
|
+
metadata: {
|
|
740
|
+
purchase_source: 'mobile_app',
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
console.log('Universal credit grant:', universalCredit);
|
|
744
|
+
return { universalCredit };
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
// 上报 Credit 消耗用量
|
|
748
|
+
async reportUsage(customerId, minutes, sessionContext) {
|
|
749
|
+
const creditBalance = await payment.creditGrants.summary({
|
|
750
|
+
customer_id: customerId,
|
|
751
|
+
});
|
|
752
|
+
console.log('creditBalance', creditBalance);
|
|
753
|
+
const meterEvent = await payment.meterEvents.create({
|
|
754
|
+
event_name: 'api_calls',
|
|
755
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
756
|
+
payload: {
|
|
757
|
+
customer_id: customerId,
|
|
758
|
+
value: String(minutes),
|
|
759
|
+
subscription_id: sessionContext.subscriptionId || undefined,
|
|
760
|
+
},
|
|
761
|
+
identifier: `${customerId}_${sessionContext.sessionId}_${Date.now()}`,
|
|
762
|
+
metadata: {
|
|
763
|
+
session_id: sessionContext.sessionId,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
event_id: meterEvent.id,
|
|
769
|
+
identifier: meterEvent.identifier,
|
|
770
|
+
timestamp: meterEvent.timestamp,
|
|
771
|
+
};
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
|
|
551
775
|
const testModules = {
|
|
552
776
|
checkout: checkoutModule,
|
|
553
777
|
payment: paymentModule,
|
|
554
778
|
product: productModule,
|
|
555
779
|
subscription: subscriptionModule,
|
|
780
|
+
meter: meterModule,
|
|
781
|
+
credit: creditModule,
|
|
556
782
|
};
|
|
557
783
|
|
|
558
784
|
// 测试入口
|
|
559
785
|
async function runTest() {
|
|
560
786
|
payment.environments.setTestMode(true);
|
|
561
|
-
await testModules.
|
|
787
|
+
const { meter } = await testModules.meter.createMeteredSubscription();
|
|
788
|
+
// await testModules.credit.createCreditProduct(meter);
|
|
789
|
+
// await testModules.credit.createPaymentLinks();
|
|
790
|
+
// await testModules.credit.createCreditGrant(meter);
|
|
791
|
+
await testModules.credit.reportUsage('cus_xga1PZSmiDZfz0', 2, {
|
|
792
|
+
sessionId: 'session_123',
|
|
793
|
+
});
|
|
562
794
|
}
|
|
563
795
|
|
|
564
796
|
async function main() {
|
package/src/app.tsx
CHANGED
|
@@ -27,6 +27,7 @@ const CustomerSubscriptionDetail = React.lazy(() => import('./pages/customer/sub
|
|
|
27
27
|
const CustomerSubscriptionEmbed = React.lazy(() => import('./pages/customer/subscription/embed'));
|
|
28
28
|
const CustomerSubscriptionChangePlan = React.lazy(() => import('./pages/customer/subscription/change-plan'));
|
|
29
29
|
const CustomerSubscriptionChangePayment = React.lazy(() => import('./pages/customer/subscription/change-payment'));
|
|
30
|
+
const CustomerCreditGrantDetail = React.lazy(() => import('./pages/customer/credit-grant/detail'));
|
|
30
31
|
const CustomerRecharge = React.lazy(() => import('./pages/customer/recharge/subscription'));
|
|
31
32
|
const CustomerPayoutDetail = React.lazy(() => import('./pages/customer/payout/detail'));
|
|
32
33
|
const IntegrationsPage = React.lazy(() => import('./pages/integrations'));
|
|
@@ -149,6 +150,15 @@ function App() {
|
|
|
149
150
|
</UserLayout>
|
|
150
151
|
}
|
|
151
152
|
/>
|
|
153
|
+
<Route
|
|
154
|
+
key="customer-credit-grant"
|
|
155
|
+
path="/customer/credit-grant/:id"
|
|
156
|
+
element={
|
|
157
|
+
<UserLayout>
|
|
158
|
+
<CustomerCreditGrantDetail />
|
|
159
|
+
</UserLayout>
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
152
162
|
<Route key="customer-fallback" path="/customer/*" element={<Navigate to="/customer" />} />,
|
|
153
163
|
<Route path="*" element={<Navigate to="/" />} />
|
|
154
164
|
</Routes>
|
|
@@ -12,6 +12,7 @@ type Props = {
|
|
|
12
12
|
value?: string;
|
|
13
13
|
onChange?: (value: string, expanded: boolean) => void;
|
|
14
14
|
lazy?: boolean;
|
|
15
|
+
card?: boolean;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export default function IconCollapse(rawProps: Props) {
|
|
@@ -24,6 +25,7 @@ export default function IconCollapse(rawProps: Props) {
|
|
|
24
25
|
addons: null,
|
|
25
26
|
style: {},
|
|
26
27
|
lazy: true,
|
|
28
|
+
card: false,
|
|
27
29
|
},
|
|
28
30
|
rawProps
|
|
29
31
|
);
|
|
@@ -54,6 +56,12 @@ export default function IconCollapse(rawProps: Props) {
|
|
|
54
56
|
fontWeight: 500,
|
|
55
57
|
color: 'text.primary',
|
|
56
58
|
'& :hover': { color: 'primary.main' },
|
|
59
|
+
...(props.card && {
|
|
60
|
+
borderRadius: 1,
|
|
61
|
+
padding: 1,
|
|
62
|
+
pl: 2,
|
|
63
|
+
backgroundColor: 'grey.100',
|
|
64
|
+
}),
|
|
57
65
|
...props.style,
|
|
58
66
|
}}>
|
|
59
67
|
<Box>{typeof props.trigger === 'function' ? props.trigger(expanded) : props.trigger}</Box>
|
|
@@ -66,7 +74,9 @@ export default function IconCollapse(rawProps: Props) {
|
|
|
66
74
|
{props.addons} {expanded ? <ExpandLessOutlined /> : <ExpandMoreOutlined />}
|
|
67
75
|
</Stack>
|
|
68
76
|
</Stack>
|
|
69
|
-
<Collapse in={expanded}
|
|
77
|
+
<Collapse in={expanded} sx={{ width: '100%' }}>
|
|
78
|
+
{expanded || props.lazy ? props.children : null}
|
|
79
|
+
</Collapse>
|
|
70
80
|
</>
|
|
71
81
|
);
|
|
72
82
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Box } from '@mui/material';
|
|
2
|
+
import { useState, ReactNode, useEffect, createContext, useContext, useMemo, useRef, useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
const ConditionalSectionContext = createContext<{
|
|
5
|
+
hideRender: (hide?: boolean) => void;
|
|
6
|
+
} | null>(null);
|
|
7
|
+
|
|
8
|
+
// 导出hook供子组件使用
|
|
9
|
+
export const useConditionalSection = () => {
|
|
10
|
+
const context = useContext(ConditionalSectionContext);
|
|
11
|
+
return context;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface ConditionalSectionProps {
|
|
15
|
+
skeleton: boolean;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
skeletonComponent?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 条件渲染组件 - 完全防闪现版本
|
|
22
|
+
*
|
|
23
|
+
* 彻底解决闪现问题的方案:
|
|
24
|
+
* 1. skeleton=true 时显示骨架屏组件
|
|
25
|
+
* 2. skeleton=false 时完全隐藏(display: none)渲染子组件,让其执行逻辑
|
|
26
|
+
* 3. 等待子组件执行完毕,如果没有调用hideRender则显示
|
|
27
|
+
* 4. 整个过程用户看不到任何闪现
|
|
28
|
+
*
|
|
29
|
+
* 使用方式:
|
|
30
|
+
* - 在任意深度的子组件中调用 useConditionalSection()?.hideRender()
|
|
31
|
+
*/
|
|
32
|
+
export default function ConditionalSection({ skeleton, children, skeletonComponent = null }: ConditionalSectionProps) {
|
|
33
|
+
const [renderState, setRenderState] = useState<'hidden' | 'visible' | 'none'>('hidden');
|
|
34
|
+
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
35
|
+
|
|
36
|
+
const handleHideRender = useCallback((hide: boolean = true) => {
|
|
37
|
+
if (timerRef.current) {
|
|
38
|
+
clearTimeout(timerRef.current);
|
|
39
|
+
timerRef.current = null;
|
|
40
|
+
}
|
|
41
|
+
setRenderState(hide ? 'none' : 'visible');
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const contextValue = useMemo(() => ({ hideRender: handleHideRender }), [handleHideRender]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!skeleton) {
|
|
48
|
+
timerRef.current = setTimeout(() => {
|
|
49
|
+
setRenderState('visible');
|
|
50
|
+
timerRef.current = null;
|
|
51
|
+
}, 3000);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 清理定时器
|
|
55
|
+
return () => {
|
|
56
|
+
if (timerRef.current) {
|
|
57
|
+
clearTimeout(timerRef.current);
|
|
58
|
+
timerRef.current = null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}, [skeleton]);
|
|
62
|
+
|
|
63
|
+
if (skeleton) {
|
|
64
|
+
return skeletonComponent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (renderState === 'none') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<ConditionalSectionContext.Provider value={contextValue}>
|
|
73
|
+
<Box
|
|
74
|
+
sx={{
|
|
75
|
+
position: renderState === 'hidden' ? 'absolute' : 'static',
|
|
76
|
+
left: renderState === 'hidden' ? '-9999px' : 'auto',
|
|
77
|
+
top: renderState === 'hidden' ? '-9999px' : 'auto',
|
|
78
|
+
visibility: renderState === 'hidden' ? 'hidden' : 'visible',
|
|
79
|
+
width: renderState === 'hidden' ? '0' : 'auto',
|
|
80
|
+
height: renderState === 'hidden' ? '0' : 'auto',
|
|
81
|
+
overflow: 'hidden',
|
|
82
|
+
}}>
|
|
83
|
+
{children}
|
|
84
|
+
</Box>
|
|
85
|
+
</ConditionalSectionContext.Provider>
|
|
86
|
+
);
|
|
87
|
+
}
|