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:
shaw committed 2026-07-06 14:34:17 +08:00
1 parent d089c5789c
commit d56e94b875
17 files changed
+180 -48

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -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;
+4
View File
@@ -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}',
+4
View File
@@ -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} 元',
+3
View File
@@ -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
+31
View File
@@ -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: "",
+9 -3
View File
@@ -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)