mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(payment): 显示订阅 CNY 换算实付金额
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user