diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue
index b7037b574e..f77dc3a9c6 100644
--- a/frontend/src/views/user/PaymentView.vue
+++ b/frontend/src/views/user/PaymentView.vue
@@ -104,9 +104,9 @@
{{ t('payment.amountLabel') }}
- {{ formatSelectedPaymentAmount(selectedPlan.price) }}
+ {{ formatSelectedPaymentAmount(subPaymentAmount) }}
{{ t('payment.fee') }} ({{ feeRate }}%)
@@ -167,7 +167,7 @@
{{ t('common.processing') }}
-
{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(feeRate > 0 ? subTotalAmount : selectedPlan.price) }}
+
{{ t('payment.createOrder') }} {{ formatSelectedPaymentAmount(subTotalAmount) }}
@@ -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
(() =>
@@ -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(() => {
- 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
)
diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts
index 4b9165a50c..d2c89c601d 100644
--- a/frontend/src/views/user/__tests__/PaymentView.spec.ts
+++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts
@@ -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 = {}) {
+ 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
+ method?: Partial
+ plan?: Partial
+} = {}) {
+ 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[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: '
',
+ },
+ 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'