mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 00:33:13 +00:00
feat(payment): 订阅 CNY 换算改为独立汇率配置的显式 opt-in
- 新增 SUBSCRIPTION_USD_TO_CNY_RATE 配置(1 USD = X CNY,默认 0=关闭), 替代复用 balance_recharge_multiplier 的隐式换算,促销倍率与订阅定价解耦 - 未配置汇率时订阅保持 price 直付的存量行为,存量部署升级零影响 - 前端确认页/原价/手续费/方式限额与后端换算条件严格镜像(rate>0 且币种为 CNY) - 管理后台新增汇率配置输入(zh/en 文案),checkout-info 透出 subscription_usd_to_cny_rate - 单测锁定:汇率未配置时不换算、换算使用汇率而非余额倍率、余额订单不受影响、返利仍按 USD price
This commit is contained in:
1 parent
d089c5789c
commit
d56e94b875
17 files changed
+180
-48
No files matched your search
@@ -24,6 +24,7 @@ export interface AdminPaymentConfig {
|
||||
enabled_payment_types: string[]
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
load_balance_strategy: string
|
||||
product_name_prefix: string
|
||||
product_name_suffix: string
|
||||
@@ -42,6 +43,7 @@ export interface UpdatePaymentConfigRequest {
|
||||
enabled_payment_types?: string[]
|
||||
balance_disabled?: boolean
|
||||
balance_recharge_multiplier?: number
|
||||
subscription_usd_to_cny_rate?: number
|
||||
load_balance_strategy?: string
|
||||
product_name_prefix?: string
|
||||
product_name_suffix?: string
|
||||
|
||||
@@ -589,6 +589,7 @@ export interface SystemSettings {
|
||||
payment_enabled_types: string[];
|
||||
payment_balance_disabled: boolean;
|
||||
payment_balance_recharge_multiplier: number;
|
||||
payment_subscription_usd_to_cny_rate: number;
|
||||
payment_recharge_fee_rate: number;
|
||||
payment_load_balance_strategy: string;
|
||||
payment_product_name_prefix: string;
|
||||
@@ -860,6 +861,7 @@ export interface UpdateSettingsRequest {
|
||||
payment_enabled_types?: string[];
|
||||
payment_balance_disabled?: boolean;
|
||||
payment_balance_recharge_multiplier?: number;
|
||||
payment_subscription_usd_to_cny_rate?: number;
|
||||
payment_recharge_fee_rate?: number;
|
||||
payment_load_balance_strategy?: string;
|
||||
payment_product_name_prefix?: string;
|
||||
|
||||
@@ -6183,6 +6183,10 @@ export default {
|
||||
balanceRechargeMultiplier: 'Balance Recharge Multiplier',
|
||||
balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid',
|
||||
balanceRechargePreview: 'Preview: 1 CNY = {usd} USD',
|
||||
subscriptionUsdToCnyRate: 'Subscription USD to CNY Rate',
|
||||
subscriptionUsdToCnyRateHint:
|
||||
'CNY charged per 1 USD of plan price on CNY channels (e.g. 7.15). 0 or empty = disabled, plan price is charged as-is. When enabled, all plan prices must be set in USD',
|
||||
subscriptionUsdToCnyRateDisabled: 'Disabled (price charged as-is)',
|
||||
rechargeFeeRate: 'Recharge Fee Rate',
|
||||
rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee',
|
||||
rechargeFeePreview: 'Preview: Recharge 100, fee {fee}',
|
||||
|
||||
@@ -6338,6 +6338,10 @@ export default {
|
||||
balanceRechargeMultiplier: '余额充值倍率',
|
||||
balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额',
|
||||
balanceRechargePreview: '预览:1 CNY = {usd} USD',
|
||||
subscriptionUsdToCnyRate: '订阅 CNY 换算汇率',
|
||||
subscriptionUsdToCnyRateHint:
|
||||
'CNY 支付通道下,套餐每 1 USD 价格收取多少 CNY(如 7.15)。0 或留空 = 不换算,订阅按 price 数值直接收款。启用后所有套餐 price 必须按 USD 定价',
|
||||
subscriptionUsdToCnyRateDisabled: '未启用(按 price 直付)',
|
||||
rechargeFeeRate: '充值手续费率',
|
||||
rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费',
|
||||
rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元',
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface PaymentConfig {
|
||||
order_timeout_minutes: number
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
subscription_usd_to_cny_rate: number
|
||||
enabled_payment_types: PaymentType[]
|
||||
help_image_url: string
|
||||
help_text: string
|
||||
@@ -66,6 +67,8 @@ export interface CheckoutInfoResponse {
|
||||
plans: SubscriptionPlan[]
|
||||
balance_disabled: boolean
|
||||
balance_recharge_multiplier: number
|
||||
/** Subscription CNY conversion rate (1 USD = X CNY); 0 = disabled, plan price is charged as-is */
|
||||
subscription_usd_to_cny_rate: number
|
||||
recharge_fee_rate: number
|
||||
help_text: string
|
||||
help_image_url: string
|
||||
|
||||
@@ -6480,6 +6480,34 @@
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.settings.payment.subscriptionUsdToCnyRate")
|
||||
}}</label>
|
||||
<input
|
||||
:value="form.payment_subscription_usd_to_cny_rate || ''"
|
||||
@input="
|
||||
form.payment_subscription_usd_to_cny_rate =
|
||||
parseFloat(
|
||||
($event.target as HTMLInputElement).value,
|
||||
) || 0
|
||||
"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="
|
||||
t(
|
||||
'admin.settings.payment.subscriptionUsdToCnyRateDisabled',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
{{
|
||||
t("admin.settings.payment.subscriptionUsdToCnyRateHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{
|
||||
t("admin.settings.payment.rechargeFeeRate")
|
||||
@@ -8037,6 +8065,7 @@ const form = reactive<SettingsForm>({
|
||||
payment_order_timeout_minutes: 30,
|
||||
payment_balance_disabled: false,
|
||||
payment_balance_recharge_multiplier: 1,
|
||||
payment_subscription_usd_to_cny_rate: 0,
|
||||
payment_recharge_fee_rate: 0,
|
||||
payment_enabled_types: [],
|
||||
payment_help_image_url: "",
|
||||
@@ -9538,6 +9567,8 @@ async function saveSettings() {
|
||||
payment_balance_disabled: form.payment_balance_disabled,
|
||||
payment_balance_recharge_multiplier:
|
||||
Number(form.payment_balance_recharge_multiplier) || 1,
|
||||
payment_subscription_usd_to_cny_rate:
|
||||
Number(form.payment_subscription_usd_to_cny_rate) || 0,
|
||||
payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
|
||||
payment_enabled_types: form.payment_enabled_types,
|
||||
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
||||
|
||||
@@ -412,6 +412,7 @@ const baseSettingsResponse = {
|
||||
payment_enabled_types: [],
|
||||
payment_balance_disabled: false,
|
||||
payment_balance_recharge_multiplier: 1,
|
||||
payment_subscription_usd_to_cny_rate: 0,
|
||||
payment_recharge_fee_rate: 0,
|
||||
payment_load_balance_strategy: "round-robin",
|
||||
payment_product_name_prefix: "",
|
||||
|
||||
@@ -494,7 +494,7 @@ function onPaymentSettled() {
|
||||
// All checkout data from single API call
|
||||
const checkout = ref<CheckoutInfoResponse>({
|
||||
methods: {}, global_min: 0, global_max: 0,
|
||||
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, subscription_usd_to_cny_rate: 0, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -511,6 +511,11 @@ const balanceRechargeMultiplier = computed(() => {
|
||||
const multiplier = checkout.value.balance_recharge_multiplier
|
||||
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1
|
||||
})
|
||||
// 订阅 CNY 换算汇率(1 USD = X CNY)。0 = 未配置,订阅保持 price 直付(与后端 opt-in 条件严格镜像)。
|
||||
const subscriptionUsdToCnyRate = computed(() => {
|
||||
const rate = checkout.value.subscription_usd_to_cny_rate
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : 0
|
||||
})
|
||||
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
@@ -580,8 +585,9 @@ function ceilPaymentAmount(value: number, currency: string): number {
|
||||
}
|
||||
|
||||
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
|
||||
if (currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
|
||||
return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency)
|
||||
const rate = subscriptionUsdToCnyRate.value
|
||||
if (rate <= 0 || currency !== DEFAULT_PAYMENT_CURRENCY) return roundPaymentAmount(value, currency)
|
||||
return roundPaymentAmount(value * rate, currency)
|
||||
}
|
||||
|
||||
function formatSelectedPaymentAmount(value: number): string {
|
||||
|
||||
@@ -105,6 +105,7 @@ function checkoutInfoFixture(overrides: Partial<CheckoutInfoResponse> = {}) {
|
||||
plans: [],
|
||||
balance_disabled: false,
|
||||
balance_recharge_multiplier: 1,
|
||||
subscription_usd_to_cny_rate: 0,
|
||||
recharge_fee_rate: 0,
|
||||
help_text: '',
|
||||
help_image_url: '',
|
||||
@@ -236,10 +237,11 @@ async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoW
|
||||
}
|
||||
|
||||
describe('PaymentView subscription confirmation amounts', () => {
|
||||
it('shows converted CNY pay amount for plan price, original price, and create button', async () => {
|
||||
it('shows converted CNY pay amount using the subscription rate, not the balance multiplier', async () => {
|
||||
const wrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
},
|
||||
method: {
|
||||
currency: 'CNY',
|
||||
@@ -251,19 +253,23 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
const convertedPrice = formatPaymentAmount(71.36, 'CNY')
|
||||
const convertedOriginalPrice = formatPaymentAmount(92.79, 'CNY')
|
||||
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
|
||||
const convertedOriginalPrice = formatPaymentAmount(92.88, 'CNY')
|
||||
|
||||
expect(text).toContain(convertedPrice)
|
||||
expect(text).toContain(convertedOriginalPrice)
|
||||
expect(text).not.toContain(formatPaymentAmount(9.99, 'CNY'))
|
||||
// 换算必须使用订阅汇率(×7.15),而不是余额倍率(÷0.14 = 71.36)
|
||||
expect(text).not.toContain(formatPaymentAmount(71.36, 'CNY'))
|
||||
expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
|
||||
it('keeps plan price when the subscription rate is not configured or payment currency is not CNY', async () => {
|
||||
// opt-in 回归锁:即使余额倍率已配置,未配置订阅汇率时 CNY 订阅仍按 price 直付
|
||||
const cnyWrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0,
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 0,
|
||||
},
|
||||
method: {
|
||||
currency: 'CNY',
|
||||
@@ -275,10 +281,11 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
|
||||
expect(cnyWrapper.text()).toContain(formatPaymentAmount(7.99, 'CNY'))
|
||||
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.07, 'CNY'))
|
||||
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.13, 'CNY'))
|
||||
|
||||
const usdWrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
},
|
||||
method: {
|
||||
currency: 'USD',
|
||||
@@ -293,10 +300,10 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
|
||||
})
|
||||
|
||||
it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => {
|
||||
it('adds fee rate after CNY rate conversion to match backend pay_amount', async () => {
|
||||
const wrapper = await mountSubscriptionConfirm({
|
||||
checkout: {
|
||||
balance_recharge_multiplier: 0.14,
|
||||
subscription_usd_to_cny_rate: 7.15,
|
||||
recharge_fee_rate: 2.5,
|
||||
},
|
||||
method: {
|
||||
@@ -308,9 +315,9 @@ describe('PaymentView subscription confirmation amounts', () => {
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
const convertedPrice = formatPaymentAmount(71.36, 'CNY')
|
||||
const convertedPrice = formatPaymentAmount(71.43, 'CNY')
|
||||
const fee = formatPaymentAmount(1.79, 'CNY')
|
||||
const total = formatPaymentAmount(73.15, 'CNY')
|
||||
const total = formatPaymentAmount(73.22, 'CNY')
|
||||
|
||||
expect(text).toContain(convertedPrice)
|
||||
expect(text).toContain(fee)
|
||||
|
||||
Reference in new issue
Block a user