diff --git a/apps/api/src/services/currency-priority.test.ts b/apps/api/src/services/currency-priority.test.ts new file mode 100644 index 0000000..ec9e6b7 --- /dev/null +++ b/apps/api/src/services/currency-priority.test.ts @@ -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') + }) +}) diff --git a/apps/api/src/services/veesp/mappers.test.ts b/apps/api/src/services/veesp/mappers.test.ts index cc8becc..d750eab 100644 --- a/apps/api/src/services/veesp/mappers.test.ts +++ b/apps/api/src/services/veesp/mappers.test.ts @@ -80,6 +80,66 @@ describe('mapVpsRecordToVps', () => { expect(vps.externalId).toBe('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', () => { diff --git a/apps/api/src/services/veesp/mappers.ts b/apps/api/src/services/veesp/mappers.ts index d2ee8a6..82a7989 100644 --- a/apps/api/src/services/veesp/mappers.ts +++ b/apps/api/src/services/veesp/mappers.ts @@ -63,21 +63,57 @@ function ratesFromTotal( return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) } } -function pickPrimaryIp(ips: VeespVpsRecord['ips'], vm: VeespVpsRecord['vm'], domain?: string): string { - const vmIp = String(vm?.ip ?? vm?.ipv4 ?? '').trim() +function isIpv4(value: string): boolean { + 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 + const infoIp = normalizeIpField(info?.ip) + if (infoIp) return infoIp + for (const item of ips) { - const ip = String(item.ip ?? item.address ?? '').trim() + const ip = ipFromItem(item) if (!ip) continue if (item.main === true || item.main === 1 || String(item.main) === '1') return ip } for (const item of ips) { - const ip = String(item.ip ?? item.address ?? '').trim() + const ip = ipFromItem(item) if (ip) return ip } const d = String(domain ?? '').trim() - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(d)) return d + if (isIpv4(d)) return d return '' } @@ -157,13 +193,23 @@ export function mapVpsRecordToVps( const total = parseNumber(detail.total ?? service.total) const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle) 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() - const ip = pickPrimaryIp(ips, vm, detail.domain ?? service.domain) - const os = String(vm?.os ?? vm?.template ?? info?.os ?? info?.template ?? '').trim() - const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? info?.cpu) - const ramGb = parseNumber(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.memory) - const diskGb = parseNumber(vm?.disk ?? info?.disk) + const ip = pickPrimaryIp(ips, vm, info, detail.domain ?? service.domain) + const os = String( + vm?.os ?? vm?.template ?? vm?.template_label ?? info?.os ?? info?.template ?? info?.template_label ?? '', + ).trim() + 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( pickServiceStatus(service.status, detail.status), vm?.status ?? vm?.state, diff --git a/apps/api/src/services/veesp/operations.test.ts b/apps/api/src/services/veesp/operations.test.ts new file mode 100644 index 0000000..8240ad0 --- /dev/null +++ b/apps/api/src/services/veesp/operations.test.ts @@ -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(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(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(json, 'ips') + expect(ips).toHaveLength(2) + expect(ips[0]?.ipaddress).toBe('198.51.100.5') + expect(ips[0]?.id).toBe('1') + }) +}) diff --git a/apps/api/src/services/veesp/operations.ts b/apps/api/src/services/veesp/operations.ts index e8073d7..33ff263 100644 --- a/apps/api/src/services/veesp/operations.ts +++ b/apps/api/src/services/veesp/operations.ts @@ -41,13 +41,16 @@ export interface VeespVmListItem { id?: string | number vmid?: string | number name?: string + label?: string hostname?: string status?: string state?: string - ip?: string - ipv4?: string + ip?: string | string[] + ipv4?: string | string[] template?: string + template_label?: string os?: string + cpus?: string | number } export interface VeespVmDetail extends VeespVmListItem { @@ -61,6 +64,7 @@ export interface VeespVmDetail extends VeespVmListItem { export interface VeespIpItem { ip?: string address?: string + ipaddress?: string type?: string main?: boolean | number | string } @@ -68,11 +72,16 @@ export interface VeespIpItem { export interface VeespServiceInfo { os?: string template?: string + template_label?: string cpu?: number | string + cpus?: number | string ram?: number | string + ramGb?: number | string memory?: number | string disk?: number | string + hdd?: number | string hostname?: string + ip?: string | string[] } export interface VeespInvoice { @@ -176,14 +185,44 @@ export function isVpsService(service: VeespServiceListItem, vpsCategoryIds?: Set return false } +function unwrapKeyedList>(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)) { + 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 + out.push({ ...row, id: row.id ?? key } as T) + } + return out +} + function unwrapList(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') { const obj = json as Record 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(list) + } const vms = obj.vms if (Array.isArray(vms)) return vms as T[] + if (vms && typeof vms === 'object') return unwrapKeyedList(vms) } return [] } @@ -240,7 +279,13 @@ export async function fetchVmDetail( const client = clientFor(baseUrl, credentials) try { const json = await client.request(`/service/${serviceId}/vms/${vmId}`) - return unwrapObject(json, 'vm') ?? unwrapObject(json, 'vms') ?? (json as VeespVmDetail) + if (!json || typeof json !== 'object') return null + const obj = json as Record + const nested = unwrapObject(json, 'vm') ?? unwrapObject(json, 'vms') + if (nested) return { ...nested, id: nested.id ?? vmId } + const keyed = unwrapKeyedList(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 { return null } @@ -456,6 +501,11 @@ export async function fetchVpsRecords( return records } +/** Парсинг коллекций Veesp API (object-map и массивы) — для тестов и отладки */ +export function parseVeespCollection(json: unknown, key: string): T[] { + return unwrapList(json, key) +} + export async function testConnection( baseUrl: string, credentials: string, diff --git a/apps/api/src/services/veesp/sync.test.ts b/apps/api/src/services/veesp/sync.test.ts index 653ea83..755462d 100644 --- a/apps/api/src/services/veesp/sync.test.ts +++ b/apps/api/src/services/veesp/sync.test.ts @@ -143,4 +143,18 @@ describe('syncFromVeesp', () => { expect(vps.notes).toContain('veesp-100-200') 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') + }) }) diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index 809c13c..a912dcd 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -22,6 +22,7 @@ const EMPTY: ProviderAccountFormValues = { apiLogin: '', apiPassword: '', billingMode: 'monthly', + currency: '', balanceAlertBelow: '', notes: '', } @@ -182,6 +183,21 @@ export function ProviderAccountEditSheet({ ]} /> + {provider?.apiType === 'veesp' ? ( + + + + ) : null}