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 @@
- {{ formatSelectedPaymentAmount(selectedPlan.original_price) }} + {{ formatSelectedSubscriptionPaymentAmount(selectedPlan.original_price) }} - {{ formatSelectedPaymentAmount(selectedPlan.price) }} + {{ formatSelectedSubscriptionPaymentAmount(selectedPlan.price) }} / {{ planValiditySuffix }}
@@ -150,7 +150,7 @@
{{ 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'