fix(payment): prevent configured currencies from looking like USD

Subscription plans can carry a display-only ISO currency code, but the admin plan table and user plan cards still used a hard-coded dollar sign. Resolve the symbol through the shared currency helper while retaining existing number formatting, the currency code label, and legacy blank-currency behavior.

Constraint: Plans without a currency must keep the legacy USD display

Rejected: Reformat complete prices with Intl.NumberFormat | it would change existing decimals and remove the explicit currency code

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep plan currency symbols aligned across admin and user displays

Tested: targeted Vitest 4 tests; targeted ESLint; vue-tsc; Vite production build; full Vitest 1222 tests passed

Not-tested: Full Vitest has 2 unrelated upstream failures in admin.system.rollback.spec.ts

Related: https://github.com/Wei-Shaw/sub2api/issues/4609
This commit is contained in:
yan9651688
2026-07-20 10:33:04 +08:00
parent 1b4029b050
commit a05b873215
4 changed files with 129 additions and 5 deletions
@@ -26,13 +26,13 @@
</div>
<div class="shrink-0 text-right">
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
<span class="text-xs text-gray-400 dark:text-dark-500">{{ planCurrencySymbol }}</span>
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
<span v-if="plan.currency" class="text-xs font-medium text-gray-400 dark:text-dark-500">{{ plan.currency }}</span>
</div>
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}<template v-if="plan.currency"> {{ plan.currency }}</template></span>
<span class="text-xs text-gray-400 line-through dark:text-dark-500">{{ planCurrencySymbol }}{{ plan.original_price }}<template v-if="plan.currency"> {{ plan.currency }}</template></span>
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
</div>
</div>
@@ -106,6 +106,7 @@ import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import { useAppStore } from '@/stores/app'
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { currencySymbol } from '@/components/payment/currency'
import {
platformAccentBarClass,
platformBadgeLightClass,
@@ -148,6 +149,7 @@ const rateDisplay = computed(() => {
})
const appStore = useAppStore()
const planCurrencySymbol = computed(() => currencySymbol(props.plan.currency || 'USD'))
const hasPeakRate = computed(() => groupHasPeakRate(props.plan))
@@ -2,6 +2,7 @@ import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { createPinia } from "pinia";
import { createI18n } from "vue-i18n";
import type { SubscriptionPlan } from "@/types/payment";
import SubscriptionPlanCard from "../SubscriptionPlanCard.vue";
const i18n = createI18n({
@@ -25,7 +26,7 @@ const i18n = createI18n({
},
});
const mountPlanCard = (groupPlatform: string) =>
const mountPlanCard = (groupPlatform: string, overrides: Partial<SubscriptionPlan> = {}) =>
mount(SubscriptionPlanCard, {
props: {
plan: {
@@ -41,6 +42,7 @@ const mountPlanCard = (groupPlatform: string) =>
validity_unit: "day",
supported_model_scopes: ["claude", "gemini_text", "gemini_image"],
is_active: true,
...overrides,
},
},
global: { plugins: [i18n, createPinia()] },
@@ -62,4 +64,13 @@ describe("SubscriptionPlanCard", () => {
expect(text).toContain("Gemini");
expect(text).toContain("Imagen");
});
it("uses the configured currency symbol while preserving USD for legacy plans", () => {
const cnyPlan = mountPlanCard("openai", { currency: "CNY", original_price: 20 }).text();
expect(cnyPlan).toContain("¥10CNY");
expect(cnyPlan).toContain("¥20CNY");
expect(mountPlanCard("openai", { currency: "USD" }).text()).toContain("$10USD");
expect(mountPlanCard("openai", { currency: "" }).text()).toContain("$10");
});
});
@@ -29,9 +29,9 @@
</template>
<template #cell-price="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ planCurrencySymbol(row.currency) }}{{ (value ?? 0).toFixed(2) }}</span>
<span v-if="row.currency" class="ml-1 text-xs text-gray-400">{{ row.currency }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">{{ planCurrencySymbol(row.currency) }}{{ row.original_price.toFixed(2) }}</span>
</div>
</template>
<template #cell-validity_days="{ value, row }">
@@ -91,11 +91,16 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlanEditDialog from './PlanEditDialog.vue'
import { currencySymbol } from '@/components/payment/currency'
import { platformTextClass } from '@/utils/platformColors'
const { t } = useI18n()
const appStore = useAppStore()
function planCurrencySymbol(currency?: string): string {
return currencySymbol(currency || 'USD')
}
// ==================== Groups ====================
const groups = ref<AdminGroup[]>([])
@@ -0,0 +1,106 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AdminPaymentPlansView from '../AdminPaymentPlansView.vue'
const { getPlans, getConfig, getGroups } = vi.hoisted(() => ({
getPlans: vi.fn(),
getConfig: vi.fn(),
getGroups: vi.fn(),
}))
vi.mock('@/api/admin/payment', () => ({
adminPaymentAPI: {
getPlans,
getConfig,
},
}))
vi.mock('@/api/admin', () => ({
default: {
groups: {
getAll: getGroups,
},
},
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.id">
<slot name="cell-price" :value="row.price" :row="row" />
</div>
</div>
`,
}
describe('AdminPaymentPlansView', () => {
beforeEach(() => {
getGroups.mockResolvedValue([])
getConfig.mockResolvedValue({ data: {} })
getPlans.mockResolvedValue({
data: [
{
id: 1,
name: 'CNY plan',
group_id: 1,
price: 499,
original_price: 599,
currency: 'CNY',
validity_days: 30,
validity_unit: 'day',
sort_order: 0,
for_sale: true,
features: [],
},
{
id: 2,
name: 'Legacy plan',
group_id: 1,
price: 10,
original_price: 0,
currency: '',
validity_days: 30,
validity_unit: 'day',
sort_order: 0,
for_sale: true,
features: [],
},
],
})
})
it('uses the configured currency symbol and keeps legacy prices in USD', async () => {
const wrapper = mount(AdminPaymentPlansView, {
global: {
plugins: [createPinia()],
stubs: {
AppLayout: { template: '<div><slot /></div>' },
DataTable: DataTableStub,
ConfirmDialog: true,
GroupBadge: true,
Icon: true,
PlanEditDialog: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('¥499.00CNY')
expect(wrapper.text()).toContain('¥599.00')
expect(wrapper.text()).toContain('$10.00')
})
})