fix(veesp): IP и характеристики VPS при синке, валюта аккаунта
Docker / build (push) Failing after 21s
Docker / build (push) Failing after 21s
Парсинг object-map ответов API (vms/ips), cpus, memory в МБ, IP-массив. Приоритет валюты аккаунта над baseCurrency хостера; поле в UI для Veesp. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { resolveProviderCurrency } from '@cfdm/shared/utils/currency'
|
||||||
|
import { effectiveAccountBalanceCurrency } from '@cfdm/shared/utils/account-balance'
|
||||||
|
|
||||||
|
describe('resolveProviderCurrency', () => {
|
||||||
|
it('prefers account currency over provider baseCurrency', () => {
|
||||||
|
expect(resolveProviderCurrency({ baseCurrency: 'EUR' }, 'USD')).toBe('USD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to provider when account currency is empty', () => {
|
||||||
|
expect(resolveProviderCurrency({ baseCurrency: 'EUR' }, '')).toBe('EUR')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('effectiveAccountBalanceCurrency', () => {
|
||||||
|
it('uses account currency for balance display', () => {
|
||||||
|
expect(
|
||||||
|
effectiveAccountBalanceCurrency({ currency: 'USD', balance_currency: 'EUR' }, { baseCurrency: 'EUR' }),
|
||||||
|
).toBe('USD')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -80,6 +80,66 @@ describe('mapVpsRecordToVps', () => {
|
|||||||
expect(vps.externalId).toBe('32723')
|
expect(vps.externalId).toBe('32723')
|
||||||
expect(vps.notes).toContain('veesp-32723')
|
expect(vps.notes).toContain('veesp-32723')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps Proxmox VM with cpus, memory in MB and ip array', () => {
|
||||||
|
const vps = mapVpsRecordToVps(
|
||||||
|
{
|
||||||
|
...baseRecord,
|
||||||
|
vmId: '17228',
|
||||||
|
vm: {
|
||||||
|
id: '17228',
|
||||||
|
label: 'rkn0',
|
||||||
|
hostname: 'rkn0',
|
||||||
|
cpus: '2',
|
||||||
|
memory: 2048,
|
||||||
|
disk: 40,
|
||||||
|
ip: ['198.51.100.5'],
|
||||||
|
template_label: 'Debian 12',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
ips: [],
|
||||||
|
info: null,
|
||||||
|
},
|
||||||
|
'prov-1',
|
||||||
|
'acc-1',
|
||||||
|
'EUR',
|
||||||
|
)
|
||||||
|
expect(vps.externalId).toBe('32723-17228')
|
||||||
|
expect(vps.ip).toBe('198.51.100.5')
|
||||||
|
expect(vps.dns).toBe('rkn0')
|
||||||
|
expect(vps.vcpu).toBe(2)
|
||||||
|
expect(vps.ramGb).toBe(2)
|
||||||
|
expect(vps.diskGb).toBe(40)
|
||||||
|
expect(vps.os).toBe('Debian 12')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to info and ips when vm is absent', () => {
|
||||||
|
const vps = mapVpsRecordToVps(
|
||||||
|
{
|
||||||
|
...baseRecord,
|
||||||
|
vmId: null,
|
||||||
|
vm: null,
|
||||||
|
ips: [{ ipaddress: '198.51.100.9', main: true }],
|
||||||
|
info: {
|
||||||
|
hostname: 'rkn0',
|
||||||
|
cpus: 1,
|
||||||
|
memory: 1024,
|
||||||
|
hdd: 20,
|
||||||
|
template_label: 'Ubuntu 22',
|
||||||
|
ip: '198.51.100.9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'prov-1',
|
||||||
|
'acc-1',
|
||||||
|
'EUR',
|
||||||
|
)
|
||||||
|
expect(vps.ip).toBe('198.51.100.9')
|
||||||
|
expect(vps.dns).toBe('rkn0')
|
||||||
|
expect(vps.vcpu).toBe(1)
|
||||||
|
expect(vps.ramGb).toBe(1)
|
||||||
|
expect(vps.diskGb).toBe(20)
|
||||||
|
expect(vps.os).toBe('Ubuntu 22')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mapInvoiceToPayment', () => {
|
describe('mapInvoiceToPayment', () => {
|
||||||
|
|||||||
@@ -63,21 +63,57 @@ function ratesFromTotal(
|
|||||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) }
|
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickPrimaryIp(ips: VeespVpsRecord['ips'], vm: VeespVpsRecord['vm'], domain?: string): string {
|
function isIpv4(value: string): boolean {
|
||||||
const vmIp = String(vm?.ip ?? vm?.ipv4 ?? '').trim()
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIpField(raw: string | string[] | undefined | null): string {
|
||||||
|
if (raw == null) return ''
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
for (const item of raw) {
|
||||||
|
const ip = String(item).trim()
|
||||||
|
if (isIpv4(ip)) return ip
|
||||||
|
}
|
||||||
|
return String(raw[0] ?? '').trim()
|
||||||
|
}
|
||||||
|
return String(raw).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipFromItem(item: { ip?: string; address?: string; ipaddress?: string }): string {
|
||||||
|
return String(item.ip ?? item.address ?? item.ipaddress ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryGb(raw: string | number | undefined | null): number {
|
||||||
|
const n = parseNumber(raw)
|
||||||
|
if (n <= 0) return 0
|
||||||
|
// Veesp VM/info API returns memory in MB (512, 1024, 2048…)
|
||||||
|
if (n >= 256) return Math.round((n / 1024) * 10) / 10
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrimaryIp(
|
||||||
|
ips: VeespVpsRecord['ips'],
|
||||||
|
vm: VeespVpsRecord['vm'],
|
||||||
|
info: VeespVpsRecord['info'],
|
||||||
|
domain?: string,
|
||||||
|
): string {
|
||||||
|
const vmIp = normalizeIpField(vm?.ip ?? vm?.ipv4)
|
||||||
if (vmIp) return vmIp
|
if (vmIp) return vmIp
|
||||||
|
|
||||||
|
const infoIp = normalizeIpField(info?.ip)
|
||||||
|
if (infoIp) return infoIp
|
||||||
|
|
||||||
for (const item of ips) {
|
for (const item of ips) {
|
||||||
const ip = String(item.ip ?? item.address ?? '').trim()
|
const ip = ipFromItem(item)
|
||||||
if (!ip) continue
|
if (!ip) continue
|
||||||
if (item.main === true || item.main === 1 || String(item.main) === '1') return ip
|
if (item.main === true || item.main === 1 || String(item.main) === '1') return ip
|
||||||
}
|
}
|
||||||
for (const item of ips) {
|
for (const item of ips) {
|
||||||
const ip = String(item.ip ?? item.address ?? '').trim()
|
const ip = ipFromItem(item)
|
||||||
if (ip) return ip
|
if (ip) return ip
|
||||||
}
|
}
|
||||||
const d = String(domain ?? '').trim()
|
const d = String(domain ?? '').trim()
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(d)) return d
|
if (isIpv4(d)) return d
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,13 +193,23 @@ export function mapVpsRecordToVps(
|
|||||||
const total = parseNumber(detail.total ?? service.total)
|
const total = parseNumber(detail.total ?? service.total)
|
||||||
const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle)
|
const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle)
|
||||||
const hostname = String(
|
const hostname = String(
|
||||||
vm?.hostname ?? vm?.name ?? info?.hostname ?? detail.domain ?? service.domain ?? detail.name ?? service.name ?? '',
|
vm?.hostname ??
|
||||||
|
vm?.name ??
|
||||||
|
vm?.label ??
|
||||||
|
info?.hostname ??
|
||||||
|
detail.domain ??
|
||||||
|
service.domain ??
|
||||||
|
detail.name ??
|
||||||
|
service.name ??
|
||||||
|
'',
|
||||||
).trim()
|
).trim()
|
||||||
const ip = pickPrimaryIp(ips, vm, detail.domain ?? service.domain)
|
const ip = pickPrimaryIp(ips, vm, info, detail.domain ?? service.domain)
|
||||||
const os = String(vm?.os ?? vm?.template ?? info?.os ?? info?.template ?? '').trim()
|
const os = String(
|
||||||
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? info?.cpu)
|
vm?.os ?? vm?.template ?? vm?.template_label ?? info?.os ?? info?.template ?? info?.template_label ?? '',
|
||||||
const ramGb = parseNumber(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.memory)
|
).trim()
|
||||||
const diskGb = parseNumber(vm?.disk ?? info?.disk)
|
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? vm?.cpus ?? info?.cpu ?? info?.cpus)
|
||||||
|
const ramGb = parseMemoryGb(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.ramGb ?? info?.memory)
|
||||||
|
const diskGb = parseNumber(vm?.disk ?? info?.disk ?? info?.hdd)
|
||||||
const status = mapStatus(
|
const status = mapStatus(
|
||||||
pickServiceStatus(service.status, detail.status),
|
pickServiceStatus(service.status, detail.status),
|
||||||
vm?.status ?? vm?.state,
|
vm?.status ?? vm?.state,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { parseVeespCollection } from './operations.js'
|
||||||
|
import type { VeespIpItem, VeespVmListItem } from './operations.js'
|
||||||
|
|
||||||
|
describe('parseVeespCollection', () => {
|
||||||
|
it('parses vms object-map from Veesp API', () => {
|
||||||
|
const json = {
|
||||||
|
vms: {
|
||||||
|
'17228': {
|
||||||
|
label: 'rkn0',
|
||||||
|
hostname: 'rkn0',
|
||||||
|
cpus: '2',
|
||||||
|
memory: 2048,
|
||||||
|
disk: 40,
|
||||||
|
ip: ['198.51.100.5'],
|
||||||
|
template_label: 'Debian 12',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const vms = parseVeespCollection<VeespVmListItem>(json, 'vms')
|
||||||
|
expect(vms).toHaveLength(1)
|
||||||
|
expect(vms[0]?.id).toBe('17228')
|
||||||
|
expect(vms[0]?.label).toBe('rkn0')
|
||||||
|
expect(vms[0]?.cpus).toBe('2')
|
||||||
|
expect(vms[0]?.ip).toEqual(['198.51.100.5'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ips as string array', () => {
|
||||||
|
const json = { ips: ['198.51.100.5', '2001:db8::1'] }
|
||||||
|
const ips = parseVeespCollection<VeespIpItem>(json, 'ips')
|
||||||
|
expect(ips).toEqual([{ ip: '198.51.100.5' }, { ip: '2001:db8::1' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ips object-map', () => {
|
||||||
|
const json = {
|
||||||
|
ips: {
|
||||||
|
'1': { ipaddress: '198.51.100.5', main: '1' },
|
||||||
|
'2': { ipaddress: '10.0.0.2' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const ips = parseVeespCollection<VeespIpItem>(json, 'ips')
|
||||||
|
expect(ips).toHaveLength(2)
|
||||||
|
expect(ips[0]?.ipaddress).toBe('198.51.100.5')
|
||||||
|
expect(ips[0]?.id).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,13 +41,16 @@ export interface VeespVmListItem {
|
|||||||
id?: string | number
|
id?: string | number
|
||||||
vmid?: string | number
|
vmid?: string | number
|
||||||
name?: string
|
name?: string
|
||||||
|
label?: string
|
||||||
hostname?: string
|
hostname?: string
|
||||||
status?: string
|
status?: string
|
||||||
state?: string
|
state?: string
|
||||||
ip?: string
|
ip?: string | string[]
|
||||||
ipv4?: string
|
ipv4?: string | string[]
|
||||||
template?: string
|
template?: string
|
||||||
|
template_label?: string
|
||||||
os?: string
|
os?: string
|
||||||
|
cpus?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VeespVmDetail extends VeespVmListItem {
|
export interface VeespVmDetail extends VeespVmListItem {
|
||||||
@@ -61,6 +64,7 @@ export interface VeespVmDetail extends VeespVmListItem {
|
|||||||
export interface VeespIpItem {
|
export interface VeespIpItem {
|
||||||
ip?: string
|
ip?: string
|
||||||
address?: string
|
address?: string
|
||||||
|
ipaddress?: string
|
||||||
type?: string
|
type?: string
|
||||||
main?: boolean | number | string
|
main?: boolean | number | string
|
||||||
}
|
}
|
||||||
@@ -68,11 +72,16 @@ export interface VeespIpItem {
|
|||||||
export interface VeespServiceInfo {
|
export interface VeespServiceInfo {
|
||||||
os?: string
|
os?: string
|
||||||
template?: string
|
template?: string
|
||||||
|
template_label?: string
|
||||||
cpu?: number | string
|
cpu?: number | string
|
||||||
|
cpus?: number | string
|
||||||
ram?: number | string
|
ram?: number | string
|
||||||
|
ramGb?: number | string
|
||||||
memory?: number | string
|
memory?: number | string
|
||||||
disk?: number | string
|
disk?: number | string
|
||||||
|
hdd?: number | string
|
||||||
hostname?: string
|
hostname?: string
|
||||||
|
ip?: string | string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VeespInvoice {
|
export interface VeespInvoice {
|
||||||
@@ -176,14 +185,44 @@ export function isVpsService(service: VeespServiceListItem, vpsCategoryIds?: Set
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapKeyedList<T extends Record<string, unknown>>(raw: unknown): T[] {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
|
||||||
|
const out: T[] = []
|
||||||
|
for (const [key, item] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (item == null) continue
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
out.push({ ip: item, id: key } as unknown as T)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (typeof item !== 'object') continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
out.push({ ...row, id: row.id ?? key } as T)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapList<T>(json: unknown, key: string): T[] {
|
function unwrapList<T>(json: unknown, key: string): T[] {
|
||||||
if (Array.isArray(json)) return json as T[]
|
if (Array.isArray(json)) {
|
||||||
|
return json.map((item) => {
|
||||||
|
if (typeof item === 'string') return { ip: item } as T
|
||||||
|
return item as T
|
||||||
|
})
|
||||||
|
}
|
||||||
if (json && typeof json === 'object') {
|
if (json && typeof json === 'object') {
|
||||||
const obj = json as Record<string, unknown>
|
const obj = json as Record<string, unknown>
|
||||||
const list = obj[key]
|
const list = obj[key]
|
||||||
if (Array.isArray(list)) return list as T[]
|
if (Array.isArray(list)) {
|
||||||
|
return list.map((item) => {
|
||||||
|
if (typeof item === 'string') return { ip: item } as T
|
||||||
|
return item as T
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (list && typeof list === 'object') {
|
||||||
|
return unwrapKeyedList<T>(list)
|
||||||
|
}
|
||||||
const vms = obj.vms
|
const vms = obj.vms
|
||||||
if (Array.isArray(vms)) return vms as T[]
|
if (Array.isArray(vms)) return vms as T[]
|
||||||
|
if (vms && typeof vms === 'object') return unwrapKeyedList<T>(vms)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -240,7 +279,13 @@ export async function fetchVmDetail(
|
|||||||
const client = clientFor(baseUrl, credentials)
|
const client = clientFor(baseUrl, credentials)
|
||||||
try {
|
try {
|
||||||
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}`)
|
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}`)
|
||||||
return unwrapObject<VeespVmDetail>(json, 'vm') ?? unwrapObject<VeespVmDetail>(json, 'vms') ?? (json as VeespVmDetail)
|
if (!json || typeof json !== 'object') return null
|
||||||
|
const obj = json as Record<string, unknown>
|
||||||
|
const nested = unwrapObject<VeespVmDetail>(json, 'vm') ?? unwrapObject<VeespVmDetail>(json, 'vms')
|
||||||
|
if (nested) return { ...nested, id: nested.id ?? vmId }
|
||||||
|
const keyed = unwrapKeyedList<VeespVmDetail>(obj.vms ?? obj.vm ?? obj)
|
||||||
|
const match = keyed.find((row) => String(row.id ?? row.vmid) === String(vmId))
|
||||||
|
return match ?? (keyed[0] ? { ...keyed[0], id: keyed[0].id ?? vmId } : null)
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -456,6 +501,11 @@ export async function fetchVpsRecords(
|
|||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Парсинг коллекций Veesp API (object-map и массивы) — для тестов и отладки */
|
||||||
|
export function parseVeespCollection<T>(json: unknown, key: string): T[] {
|
||||||
|
return unwrapList<T>(json, key)
|
||||||
|
}
|
||||||
|
|
||||||
export async function testConnection(
|
export async function testConnection(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
credentials: string,
|
credentials: string,
|
||||||
|
|||||||
@@ -143,4 +143,18 @@ describe('syncFromVeesp', () => {
|
|||||||
expect(vps.notes).toContain('veesp-100-200')
|
expect(vps.notes).toContain('veesp-100-200')
|
||||||
expect(vps.monthlyRate).toBe(5)
|
expect(vps.monthlyRate).toBe(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses account currency over provider baseCurrency', async () => {
|
||||||
|
const result = await syncFromVeesp({
|
||||||
|
...makeAccount(),
|
||||||
|
currency: 'USD',
|
||||||
|
providerBaseCurrency: 'EUR',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.vpsCount).toBe(1)
|
||||||
|
const vps = getSqlite()
|
||||||
|
.prepare('SELECT currency FROM vps WHERE id = ?')
|
||||||
|
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
|
||||||
|
expect(vps.currency).toBe('USD')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const EMPTY: ProviderAccountFormValues = {
|
|||||||
apiLogin: '',
|
apiLogin: '',
|
||||||
apiPassword: '',
|
apiPassword: '',
|
||||||
billingMode: 'monthly',
|
billingMode: 'monthly',
|
||||||
|
currency: '',
|
||||||
balanceAlertBelow: '',
|
balanceAlertBelow: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,21 @@ export function ProviderAccountEditSheet({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{provider?.apiType === 'veesp' ? (
|
||||||
|
<FormField
|
||||||
|
label="Валюта аккаунта"
|
||||||
|
htmlFor="acc-cur"
|
||||||
|
error={errors.currency?.message}
|
||||||
|
description="USD, EUR и т.д. — у каждого аккаунта Veesp может быть своя валюта. Если пусто — берётся валюта хостера."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="acc-cur"
|
||||||
|
placeholder="EUR"
|
||||||
|
aria-invalid={!!errors.currency}
|
||||||
|
{...register('currency')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
|
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
|
||||||
<Input
|
<Input
|
||||||
id="acc-alert"
|
id="acc-alert"
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ export function toIsoCurrency(currency?: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
|
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
|
||||||
const provRaw = (provider?.baseCurrency || '').trim()
|
|
||||||
if (provRaw) return toIsoCurrency(provRaw)
|
|
||||||
const ownRaw = (vps?.currency || '').trim()
|
const ownRaw = (vps?.currency || '').trim()
|
||||||
if (ownRaw) return toIsoCurrency(ownRaw)
|
if (ownRaw) return toIsoCurrency(ownRaw)
|
||||||
|
const provRaw = (provider?.baseCurrency || '').trim()
|
||||||
|
if (provRaw) return toIsoCurrency(provRaw)
|
||||||
return 'RUB'
|
return 'RUB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const providerAccountSchema = z.object({
|
|||||||
apiLogin: z.string().optional().default(''),
|
apiLogin: z.string().optional().default(''),
|
||||||
apiPassword: z.string().optional().default(''),
|
apiPassword: z.string().optional().default(''),
|
||||||
billingMode: billingModeSchema.default('monthly'),
|
billingMode: billingModeSchema.default('monthly'),
|
||||||
|
currency: z.string().optional().default(''),
|
||||||
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ function AccountsPage() {
|
|||||||
apiLogin: a.apiLogin ?? a.login ?? '',
|
apiLogin: a.apiLogin ?? a.login ?? '',
|
||||||
apiPassword: '',
|
apiPassword: '',
|
||||||
billingMode: a.billingMode ?? 'monthly',
|
billingMode: a.billingMode ?? 'monthly',
|
||||||
|
currency: a.currency ?? '',
|
||||||
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
||||||
notes: a.notes ?? '',
|
notes: a.notes ?? '',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function accountBalanceCurrency(account: {
|
|||||||
return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB'
|
return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Валюта баланса с учётом baseCurrency хостера (как effectiveVpsTariffCurrency для тарифов). */
|
/** Валюта баланса: валюта аккаунта → baseCurrency хостера → balance_currency/currency. */
|
||||||
export function effectiveAccountBalanceCurrency(
|
export function effectiveAccountBalanceCurrency(
|
||||||
account: {
|
account: {
|
||||||
balance_currency?: string
|
balance_currency?: string
|
||||||
@@ -28,9 +28,7 @@ export function effectiveAccountBalanceCurrency(
|
|||||||
},
|
},
|
||||||
provider?: { baseCurrency?: string | null } | null,
|
provider?: { baseCurrency?: string | null } | null,
|
||||||
): string {
|
): string {
|
||||||
const provRaw = (provider?.baseCurrency ?? '').trim()
|
return resolveProviderCurrency(provider, account.currency ?? accountBalanceCurrency(account))
|
||||||
if (provRaw) return provRaw
|
|
||||||
return accountBalanceCurrency(account)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncFallbackCurrency(
|
export function syncFallbackCurrency(
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/** Валюта хостера: настройки провайдера → аккаунт → fallback. */
|
/** Валюта: явная валюта аккаунта → baseCurrency хостера → fallback. */
|
||||||
export function resolveProviderCurrency(
|
export function resolveProviderCurrency(
|
||||||
provider?: { baseCurrency?: string | null } | null,
|
provider?: { baseCurrency?: string | null } | null,
|
||||||
accountCurrency?: string | null,
|
accountCurrency?: string | null,
|
||||||
fallback = 'RUB',
|
fallback = 'RUB',
|
||||||
): string {
|
): string {
|
||||||
const fromProvider = (provider?.baseCurrency ?? '').trim()
|
|
||||||
if (fromProvider) return fromProvider
|
|
||||||
const fromAccount = (accountCurrency ?? '').trim()
|
const fromAccount = (accountCurrency ?? '').trim()
|
||||||
if (fromAccount) return fromAccount
|
if (fromAccount) return fromAccount
|
||||||
|
const fromProvider = (provider?.baseCurrency ?? '').trim()
|
||||||
|
if (fromProvider) return fromProvider
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user