fix(payment): 显示订阅 CNY 换算实付金额

This commit is contained in:
wucm667
2026-06-27 12:15:39 +08:00
parent c275422251
commit 88ca0c1d13
2 changed files with 260 additions and 69 deletions
+76 -24
View File
@@ -104,9 +104,9 @@
<!-- Price -->
<div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
{{ formatSelectedPaymentAmount(selectedPlan.original_price) }}
{{ formatSelectedSubscriptionPaymentAmount(selectedPlan.original_price) }}
</span>
<span :class="['text-3xl font-bold', planTextClass]">{{ formatSelectedPaymentAmount(selectedPlan.price) }}</span>
<span :class="['text-3xl font-bold', planTextClass]">{{ formatSelectedSubscriptionPaymentAmount(selectedPlan.price) }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div>
<!-- Description -->
@@ -150,7 +150,7 @@
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(selectedPlan.price) }}</span>
<span class="text-gray-900 dark:text-white">{{ formatSelectedPaymentAmount(subPaymentAmount) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
@@ -167,7 +167,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(feeRate > 0 ? subTotalAmount : selectedPlan.price) }}</span>
<span v-else>{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(subTotalAmount) }}</span>
</button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
</template>
@@ -275,7 +275,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -493,7 +493,7 @@ const enabledMethods = computed(() => Object.keys(visibleMethods.value))
const validAmount = computed(() => amount.value ?? 0)
const balanceRechargeMultiplier = computed(() => {
const multiplier = checkout.value.balance_recharge_multiplier
return multiplier > 0 ? multiplier : 1
return Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1
})
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
@@ -540,8 +540,49 @@ const localeCode = computed(() => {
return undefined
})
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
interface PaymentAmountFormatOptions {
subscription?: boolean
}
function currencyFractionDigits(currency: string): number {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
}).resolvedOptions().maximumFractionDigits ?? 2
} catch {
return 2
}
}
function roundPaymentAmount(value: number, currency: string): number {
if (!Number.isFinite(value)) return 0
const factor = 10 ** currencyFractionDigits(currency)
return Math.round(value * factor) / factor
}
function ceilPaymentAmount(value: number, currency: string): number {
if (!Number.isFinite(value)) return 0
const factor = 10 ** currencyFractionDigits(currency)
return Math.ceil(value * factor) / factor
}
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
if (currency !== DEFAULT_PAYMENT_CURRENCY) return value
return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency)
}
function subscriptionPaymentAmount(value: number): number {
return subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)
}
function formatSelectedPaymentAmount(value: number, options: PaymentAmountFormatOptions = {}): string {
const amount = options.subscription ? subscriptionPaymentAmount(value) : value
return formatPaymentAmount(amount, selectedCurrency.value, localeCode.value)
}
function formatSelectedSubscriptionPaymentAmount(value: number): string {
return formatSelectedPaymentAmount(value, { subscription: true })
}
const methodOptions = computed<PaymentMethodOption[]>(() =>
@@ -588,34 +629,45 @@ const canSubmit = computed(() =>
&& selectedLimit.value?.available !== false
)
// Subscription-specific: method options based on plan price
const subPaymentAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
return subscriptionPaymentAmount(price)
})
const subFeeAmount = computed(() => {
if (feeRate.value <= 0 || subPaymentAmount.value <= 0) return 0
return ceilPaymentAmount((subPaymentAmount.value * feeRate.value) / 100, selectedCurrency.value)
})
const subTotalAmount = computed(() => {
if (feeRate.value <= 0 || subPaymentAmount.value <= 0) return subPaymentAmount.value
return roundPaymentAmount(subPaymentAmount.value + subFeeAmount.value, selectedCurrency.value)
})
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
return roundPaymentAmount(paymentAmount + fee, currency)
}
// Subscription-specific: method options based on gateway pay amount
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
const planPrice = selectedPlan.value?.price ?? 0
const price = selectedPlan.value?.price ?? 0
return enabledMethods.value.map((type) => {
const ml = visibleMethods.value[type]
const currency = normalizePaymentCurrency(ml?.currency)
return {
type,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(planPrice, type),
available: ml?.available !== false && amountFitsMethod(subscriptionTotalAmountForCurrency(price, currency), type),
}
})
})
const subFeeAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return 0
return Math.ceil(((price * feeRate.value) / 100) * 100) / 100
})
const subTotalAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return price
return Math.round((price + subFeeAmount.value) * 100) / 100
})
const canSubmitSubscription = computed(() =>
selectedPlan.value !== null
&& amountFitsMethod(selectedPlan.value.price, selectedMethod.value)
&& amountFitsMethod(subTotalAmount.value, selectedMethod.value)
&& selectedLimit.value?.available !== false
)
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import PaymentView from '../PaymentView.vue'
import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow'
import { formatPaymentAmount } from '@/components/payment/currency'
import type { CheckoutInfoResponse, MethodLimit, SubscriptionPlan } from '@/types/payment'
const routeState = vi.hoisted(() => ({
path: '/purchase',
@@ -84,58 +86,74 @@ vi.mock('@/utils/device', () => ({
isMobileDevice: () => true,
}))
function checkoutInfoFixture() {
return {
data: {
methods: {
wxpay: {
daily_limit: 0,
daily_used: 0,
daily_remaining: 0,
single_min: 0,
single_max: 0,
fee_rate: 0,
available: true,
},
},
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: '',
function checkoutInfoFixture(overrides: Partial<CheckoutInfoResponse> = {}) {
const wxpayMethod: MethodLimit = {
daily_limit: 0,
daily_used: 0,
daily_remaining: 0,
single_min: 0,
single_max: 0,
fee_rate: 0,
available: true,
}
const data: CheckoutInfoResponse = {
methods: {
wxpay: wxpayMethod,
},
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: '',
}
return {
data: { ...data, ...overrides },
}
}
function checkoutInfoWithPlansFixture() {
function checkoutInfoWithPlansFixture(options: {
checkout?: Partial<CheckoutInfoResponse>
method?: Partial<MethodLimit>
plan?: Partial<SubscriptionPlan>
} = {}) {
const base = checkoutInfoFixture(options.checkout).data
const plan: SubscriptionPlan = {
id: 7,
group_id: 3,
name: 'Starter',
description: '',
price: 128,
original_price: 0,
validity_days: 30,
validity_unit: 'day',
rate_multiplier: 1,
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
features: [],
group_platform: 'openai',
sort_order: 1,
for_sale: true,
group_name: 'OpenAI',
...options.plan,
}
return {
data: {
...checkoutInfoFixture().data,
plans: [
{
id: 7,
group_id: 3,
name: 'Starter',
description: '',
price: 128,
original_price: 0,
validity_days: 30,
validity_unit: 'day',
rate_multiplier: 1,
daily_limit_usd: null,
weekly_limit_usd: null,
monthly_limit_usd: null,
features: [],
group_platform: 'openai',
sort_order: 1,
for_sale: true,
group_name: 'OpenAI',
...base,
methods: {
...base.methods,
wxpay: {
...base.methods.wxpay,
...options.method,
},
],
},
plans: [plan],
},
}
}
@@ -180,6 +198,127 @@ function oauthOrderFixture() {
}
}
async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoWithPlansFixture>[0] = {}) {
vi.useRealTimers()
routeState.path = '/purchase'
routeState.query = {
tab: 'subscription',
group: '3',
}
routerReplace.mockReset().mockResolvedValue(undefined)
routerPush.mockReset().mockResolvedValue(undefined)
routerResolve.mockClear()
createOrder.mockReset()
refreshUser.mockReset()
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
showError.mockReset()
showInfo.mockReset()
showWarning.mockReset()
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoWithPlansFixture(options))
bridgeInvoke.mockReset()
window.localStorage.clear()
;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined
const wrapper = shallowMount(PaymentView, {
global: {
stubs: {
AppLayout: {
template: '<div><slot /></div>',
},
Teleport: true,
Transition: false,
},
},
})
await flushPromises()
await flushPromises()
return wrapper
}
describe('PaymentView subscription confirmation amounts', () => {
it('shows converted CNY pay amount for plan price, original price, and create button', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
original_price: 9.99,
},
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const convertedOriginalPrice = formatPaymentAmount(71.36, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(convertedOriginalPrice)
expect(text).not.toContain(formatPaymentAmount(7.99, '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 () => {
const cnyWrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
},
})
expect(cnyWrapper.text()).toContain(formatPaymentAmount(7.99, 'CNY'))
expect(cnyWrapper.text()).not.toContain(formatPaymentAmount(57.07, 'CNY'))
const usdWrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
},
method: {
currency: 'USD',
},
plan: {
price: 7.99,
original_price: 9.99,
},
})
expect(usdWrapper.text()).toContain(formatPaymentAmount(7.99, 'USD'))
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
})
it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
recharge_fee_rate: 2.5,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
},
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const fee = formatPaymentAmount(1.43, 'CNY')
const total = formatPaymentAmount(58.5, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(fee)
expect(text).toContain(total)
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)
})
})
describe('PaymentView WeChat JSAPI flow', () => {
beforeEach(() => {
routeState.path = '/purchase'