feat(billmanager): унифицированные профили хостеров (waicore vds.vps)
Docker / build (push) Failing after 20s

Overrides по apiBaseUrl: DEFAULT + partial merge; Waicore использует func=vds.vps.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-19 22:15:13 +07:00
co-authored by Cursor
parent 6f0a238c95
commit 4dda77f118
12 changed files with 523 additions and 26 deletions
@@ -10,11 +10,19 @@ export {
fetchPayments,
fetchVdsOrderPricelist,
fetchVdsOrderPricelistAllDatacenters,
mapVdsWithProfile,
mapPaymentWithProfile,
} from './operations.js'
export { syncFromBillmanager } from './sync.js'
export { runBillmanagerAccountSync } from './sync-job.js'
export { billmanagerAccountRowForSync, resolveBillmanagerApi } from './context.js'
export {
resolveBillmanagerProfile,
DEFAULT_PROFILE,
mergeProfile,
} from './profiles/index.js'
export type { BillmanagerSyncAccount } from './context.js'
export type { BillmanagerProfile, BillmanagerProfileOverrides } from './profiles/index.js'
export type {
SyncFromBillmanagerOptions,
SyncFromBillmanagerResult,
+84 -16
View File
@@ -1,5 +1,6 @@
/**
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
* All funcs / extract keys come from resolveBillmanagerProfile(baseUrl).
*/
import { billmanagerRequest } from './client.js'
@@ -10,6 +11,11 @@ import {
parseDatacenterName,
parseTariffDesc,
} from './parsers.js'
import {
resolveBillmanagerProfile,
type BillmanagerProfile,
} from './profiles/index.js'
import type { MappedPayment, MappedVps } from './mappers.js'
export interface DashboardInfo {
balance: number
@@ -37,21 +43,60 @@ export interface TariffItem {
country?: string
}
export async function fetchVds(baseUrl: string, authinfo: string): Promise<Record<string, string>[]> {
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
const elems = extractList(data, 'vds')
function profileFor(baseUrl: string): BillmanagerProfile {
return resolveBillmanagerProfile(baseUrl)
}
/** Map raw VDS elem through profile.map.vds (+ optional enrichVds). */
export function mapVdsWithProfile(
profile: BillmanagerProfile,
item: Record<string, string>,
providerId: string,
accountId: string,
): MappedVps {
const mapped = profile.map.vds(item, providerId, accountId)
return profile.map.enrichVds ? profile.map.enrichVds(item, mapped) : mapped
}
export function mapPaymentWithProfile(
profile: BillmanagerProfile,
item: Record<string, string>,
accountId: string,
): MappedPayment | null {
return profile.map.payment(item, accountId)
}
export async function fetchVds(
baseUrl: string,
authinfo: string,
profile?: BillmanagerProfile,
): Promise<Record<string, string>[]> {
const p = profile ?? profileFor(baseUrl)
const data = await billmanagerRequest(
baseUrl,
authinfo,
p.funcs.listVds,
p.requestParams?.listVds,
)
const elems = extractList(data, p.extract.listVdsKey)
return elems.map((e) => elemToObject(e))
}
export async function fetchDashboardInfo(
baseUrl: string,
authinfo: string,
opts: { fallbackCurrency?: string | null } = {},
opts: { fallbackCurrency?: string | null; profile?: BillmanagerProfile } = {},
): Promise<DashboardInfo> {
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
dashboard: 'info',
sfrom: 'ajax',
})
const p = opts.profile ?? profileFor(baseUrl)
const data = await billmanagerRequest(
baseUrl,
authinfo,
p.funcs.dashboard,
p.requestParams?.dashboard ?? {
dashboard: 'info',
sfrom: 'ajax',
},
)
const elems =
extractList(data, 'dashboard') ||
(Array.isArray(data.elem) ? (data.elem as unknown[]) : [])
@@ -83,32 +128,48 @@ export async function fetchPayments(
createdate?: string
filter?: string
status?: string | number
profile?: BillmanagerProfile
} = {},
): Promise<Record<string, string>[]> {
const params: Record<string, string> = {}
const p = opts.profile ?? profileFor(baseUrl)
const params: Record<string, string | number> = {
...p.requestParams?.payments,
}
if (opts.createdatestart) params.createdatestart = opts.createdatestart
if (opts.createdateend) params.createdateend = opts.createdateend
if (opts.createdate === 'other') params.createdate = 'other'
if (opts.filter === 'on') params.filter = 'on'
if (opts.status != null) params.status = String(opts.status)
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
const elems = extractList(data, 'payment')
const data = await billmanagerRequest(baseUrl, authinfo, p.funcs.payments, params)
const elems = extractList(data, p.extract.paymentsKey)
return elems.map((e) => elemToObject(e))
}
export async function fetchVdsOrderPricelist(
baseUrl: string,
authinfo: string,
opts: { plid?: string; period?: string; datacenter?: string } = {},
opts: {
plid?: string
period?: string
datacenter?: string
profile?: BillmanagerProfile
} = {},
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
const params: Record<string, string> = {
const p = opts.profile ?? profileFor(baseUrl)
const params: Record<string, string | number> = {
plid: opts.plid || '',
sfrom: 'ajax',
...p.requestParams?.orderPricelist,
}
if (opts.period) params.period = opts.period
if (opts.datacenter) params.datacenter = opts.datacenter
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
const data = await billmanagerRequest(
baseUrl,
authinfo,
p.funcs.orderPricelist,
params,
)
const tariflist = extractTariflist(data)
const listNode = (data.list as Record<string, unknown>) ?? (data.doc as Record<string, unknown>)?.list
@@ -143,8 +204,10 @@ export async function fetchVdsOrderPricelist(
export async function fetchVdsOrderPricelistAllDatacenters(
baseUrl: string,
authinfo: string,
profile?: BillmanagerProfile,
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
const p = profile ?? profileFor(baseUrl)
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo, { profile: p })
const slist = initial.slist || {}
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
@@ -161,7 +224,12 @@ export async function fetchVdsOrderPricelistAllDatacenters(
const { country, location } = parseDatacenterName(dcName)
const result =
i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
i === 0
? initial
: await fetchVdsOrderPricelist(baseUrl, authinfo, {
datacenter: dcKey,
profile: p,
})
for (const t of result.tariffItems) {
allTariffItems.push({
...t,
@@ -0,0 +1,138 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { elemToObject, extractList } from './parsers.js'
import { mapVdsToVps } from './mappers.js'
import {
DEFAULT_PROFILE,
mergeProfile,
resolveBillmanagerProfile,
waicoreOverrides,
} from './profiles/index.js'
import { fetchVds, mapVdsWithProfile } from './operations.js'
/** Minimal Waicore-style bjson (no credentials / addon noise). */
const WAICORE_VDS_FIXTURE = {
func: 'vds.vps',
elem: [
{
id: '87173',
ip: '212.192.246.214',
domain: 'instance87173.waicore.network',
expiredate: '2027-01-21',
real_expiredate: '2027-01-21',
ostempl: 'Ubuntu 24.04',
datacentername: '[DE] Франкфурт | Промо',
pricelist: '[DE] RP-1',
cost: '1.80 € / Месяц',
item_cost: '10.8000',
currency_str: '€',
createdate: '2025-07-15',
item_status_orig: '2',
item_status: 'Активен',
},
],
}
describe('billmanager profiles', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('resolve: waicore hostname → waicore profile', () => {
const p = resolveBillmanagerProfile('https://my.waicore.com/')
expect(p.id).toBe('waicore')
expect(p.funcs.listVds).toBe('vds.vps')
expect(p.funcs.payments).toBe('payment')
expect(p.funcs.dashboard).toBe('dashboard.info')
})
it('resolve: keyword in URL → waicore', () => {
expect(resolveBillmanagerProfile('https://panel.example/waicore-proxy').id).toBe(
'waicore',
)
})
it('resolve: unknown hoster → default', () => {
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
expect(p.id).toBe('default')
expect(p.funcs.listVds).toBe('vds')
})
it('merge: only overrides listVds func', () => {
const merged = mergeProfile(DEFAULT_PROFILE, waicoreOverrides)
expect(merged.funcs.listVds).toBe('vds.vps')
expect(merged.funcs.payments).toBe(DEFAULT_PROFILE.funcs.payments)
expect(merged.extract.listVdsKey).toBe(DEFAULT_PROFILE.extract.listVdsKey)
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
})
it('Waicore fixture elem → MappedVps fields', () => {
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
expect(elems).toHaveLength(1)
const item = elemToObject(elems[0]!)
const vps = mapVdsWithProfile(profile, item, 'prov-1', 'acc-1')
expect(vps.externalId).toBe('87173')
expect(vps.ip).toBe('212.192.246.214')
expect(vps.dns).toBe('instance87173.waicore.network')
expect(vps.paidUntil).toBe('2027-01-21')
expect(vps.os).toBe('Ubuntu 24.04')
expect(vps.status).toBe('active')
})
it('fetchVds for waicore URL uses func=vds.vps', async () => {
const calls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
calls.push(url)
return {
ok: true,
json: async () => WAICORE_VDS_FIXTURE,
}
}),
)
const items = await fetchVds('https://my.waicore.com/', 'user:pass')
expect(calls).toHaveLength(1)
expect(calls[0]).toContain('func=vds.vps')
expect(items).toHaveLength(1)
expect(items[0]?.id).toBe('87173')
})
it('fetchVds for default URL uses func=vds', async () => {
const calls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
calls.push(url)
return {
ok: true,
json: async () => ({ elem: [] }),
}
}),
)
await fetchVds('https://bill.example.com/', 'user:pass')
expect(calls[0]).toContain('func=vds')
expect(calls[0]).not.toContain('func=vds.vps')
})
it('default mapVds still works without profile enrich', () => {
const mapped = mapVdsToVps(
{
id: '1',
ip: '1.2.3.4',
domain: '',
expiredate: '2026-12-01',
item_status_orig: '2',
},
'p',
'a',
)
expect(mapped.paidUntil).toBe('2026-12-01')
})
})
@@ -0,0 +1,46 @@
# BILLmanager hoster profiles
Унифицированные переопределители для исключений из стандартного ISPsystem API.
## Как добавить профиль (5 минут)
1. Создай `profiles/<id>.ts` с **только расхождениями**:
```ts
import type { BillmanagerProfileOverrides } from './types.js'
export const myHosterOverrides: BillmanagerProfileOverrides = {
id: 'myhoster',
match: {
hostnames: ['myhoster.com'],
keywords: ['myhoster'],
},
funcs: {
listVds: 'vds.custom', // если отличается от 'vds'
},
// map: { enrichVds: (item, mapped) => ({ ...mapped, country: 'DE' }) },
// requestParams: { listVds: { p_cnt: 1000 } },
}
```
2. Зарегистрируй в [`registry.ts`](./registry.ts) — массив `PROFILE_OVERRIDES` (порядок = приоритет матча).
3. Добавь fixture + тест в [`profiles.test.ts`](../profiles.test.ts).
4. Новый hook (редко) — расширь `BillmanagerProfile` в [`types.ts`](./types.ts) и значение в [`default.ts`](./default.ts).
## Контракт
| Поле | Назначение |
|------|------------|
| `match.hostnames` | substring hostname |
| `match.keywords` | substring всего URL |
| `funcs.*` | `func=` для list / payment / dashboard / order |
| `extract.*` | ключ для `extractList` |
| `map.vds` / `map.payment` | полный маппер |
| `map.enrichVds` | пост-обработка после default map |
| `requestParams.*` | доп. query params |
`resolveBillmanagerProfile(apiBaseUrl)``merge(DEFAULT, override)` или `DEFAULT`.
Sync и operations **не** содержат `if (hoster)` — только профиль.
@@ -0,0 +1,28 @@
import { mapPaymentToPayment, mapVdsToVps } from '../mappers.js'
import type { BillmanagerProfile } from './types.js'
/** Standard ISPsystem BILLmanager 6 behaviour. */
export const DEFAULT_PROFILE: BillmanagerProfile = {
id: 'default',
match: {},
funcs: {
listVds: 'vds',
payments: 'payment',
dashboard: 'dashboard.info',
orderPricelist: 'vds.order',
},
extract: {
listVdsKey: 'vds',
paymentsKey: 'payment',
},
map: {
vds: mapVdsToVps,
payment: mapPaymentToPayment,
},
requestParams: {
dashboard: {
dashboard: 'info',
sfrom: 'ajax',
},
},
}
@@ -0,0 +1,13 @@
export { DEFAULT_PROFILE } from './default.js'
export { mergeProfile } from './merge.js'
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
export type {
BillmanagerExtract,
BillmanagerFuncs,
BillmanagerMap,
BillmanagerMatch,
BillmanagerProfile,
BillmanagerProfileOverrides,
BillmanagerRequestParams,
} from './types.js'
export { waicoreOverrides } from './waicore.js'
@@ -0,0 +1,50 @@
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
/**
* Merge DEFAULT profile with hoster overrides.
* Nested funcs/extract/map are shallow-merged; requestParams replaced per-key.
*/
export function mergeProfile(
defaults: BillmanagerProfile,
overrides: BillmanagerProfileOverrides,
): BillmanagerProfile {
return {
id: overrides.id,
match: {
...defaults.match,
...overrides.match,
},
funcs: {
...defaults.funcs,
...overrides.funcs,
},
extract: {
...defaults.extract,
...overrides.extract,
},
map: {
...defaults.map,
...overrides.map,
},
requestParams: {
...defaults.requestParams,
...overrides.requestParams,
listVds: {
...defaults.requestParams?.listVds,
...overrides.requestParams?.listVds,
},
payments: {
...defaults.requestParams?.payments,
...overrides.requestParams?.payments,
},
dashboard: {
...defaults.requestParams?.dashboard,
...overrides.requestParams?.dashboard,
},
orderPricelist: {
...defaults.requestParams?.orderPricelist,
...overrides.requestParams?.orderPricelist,
},
},
}
}
@@ -0,0 +1,44 @@
import { DEFAULT_PROFILE } from './default.js'
import { mergeProfile } from './merge.js'
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
import { waicoreOverrides } from './waicore.js'
/**
* Hoster override list — first match wins.
* Add new hosters here after creating profiles/<id>.ts.
*/
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides]
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
const match = override.match
if (!match) return false
let hostname = ''
try {
hostname = new URL(url).hostname.toLowerCase()
} catch {
hostname = ''
}
const haystack = url.toLowerCase()
if (match.hostnames?.some((h) => hostname.includes(h.toLowerCase()))) {
return true
}
if (match.keywords?.some((k) => haystack.includes(k.toLowerCase()))) {
return true
}
return false
}
/** Resolve profile for apiBaseUrl: first matching override merged onto DEFAULT, else DEFAULT. */
export function resolveBillmanagerProfile(apiBaseUrl: string): BillmanagerProfile {
const url = String(apiBaseUrl || '').trim()
if (!url) return DEFAULT_PROFILE
for (const override of PROFILE_OVERRIDES) {
if (matchesUrl(url, override)) {
return mergeProfile(DEFAULT_PROFILE, override)
}
}
return DEFAULT_PROFILE
}
@@ -0,0 +1,68 @@
/**
* Unified BILLmanager hoster profile contract.
* DEFAULT fills all hooks; hoster files declare only overrides.
*/
import type { MappedPayment, MappedVps } from '../mappers.js'
export type BillmanagerMatch = {
/** Hostname substring match (e.g. waicore.com) */
hostnames?: string[]
/** Full URL lowercase substring (e.g. waicore) */
keywords?: string[]
}
export type BillmanagerFuncs = {
listVds: string
payments: string
dashboard: string
orderPricelist: string
}
export type BillmanagerExtract = {
listVdsKey: string
paymentsKey: string
}
export type BillmanagerMap = {
vds: (
item: Record<string, string>,
providerId: string,
accountId: string,
) => MappedVps
payment: (
item: Record<string, string>,
accountId: string,
) => MappedPayment | null
/** Optional post-process after map.vds (specs / geo / etc.) */
enrichVds?: (
item: Record<string, string>,
mapped: MappedVps,
) => MappedVps
}
export type BillmanagerRequestParams = {
listVds?: Record<string, string | number>
payments?: Record<string, string | number>
dashboard?: Record<string, string | number>
orderPricelist?: Record<string, string | number>
}
export type BillmanagerProfile = {
id: string
match: BillmanagerMatch
funcs: BillmanagerFuncs
extract: BillmanagerExtract
map: BillmanagerMap
requestParams?: BillmanagerRequestParams
}
/** Deep-partial for hoster override files (only divergences). */
export type BillmanagerProfileOverrides = {
id: string
match?: BillmanagerMatch
funcs?: Partial<BillmanagerFuncs>
extract?: Partial<BillmanagerExtract>
map?: Partial<BillmanagerMap>
requestParams?: BillmanagerRequestParams
}
@@ -0,0 +1,16 @@
import type { BillmanagerProfileOverrides } from './types.js'
/**
* Waicore (my.waicore.com) — list VPS via func=vds.vps instead of vds.
* Response uses top-level elem[] (covered by extractList).
*/
export const waicoreOverrides: BillmanagerProfileOverrides = {
id: 'waicore',
match: {
hostnames: ['waicore.com', 'waicore.network'],
keywords: ['waicore'],
},
funcs: {
listVds: 'vds.vps',
},
}
+26 -10
View File
@@ -7,15 +7,17 @@ import { getDb, schema } from '@cfdm/db'
import type { BillmanagerSyncAccount } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
import { mapPaymentToPayment, mapVdsToVps } from './mappers.js'
import {
fetchDashboardInfo,
fetchPayments,
fetchVds,
fetchVdsOrderPricelistAllDatacenters,
mapPaymentWithProfile,
mapVdsWithProfile,
type DashboardInfo,
type TariffItem,
} from './operations.js'
import { resolveBillmanagerProfile } from './profiles/index.js'
export interface SyncFromBillmanagerOptions {
skipTariffs?: boolean
@@ -69,6 +71,7 @@ export async function syncFromBillmanager(
}
const authinfo = apiCredentials.trim()
const db = getDb()
const profile = resolveBillmanagerProfile(apiBaseUrl)
const fetchVpsPayments = !skipVpsPayments
const fetchTariffs = !skipTariffs
@@ -76,16 +79,29 @@ export async function syncFromBillmanager(
const fallbackCurrency = syncFallbackCurrency(account)
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo, profile) : [],
fetchVpsPayments
? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency }).catch(() => null)
? fetchPayments(apiBaseUrl, authinfo, { profile })
: [],
fetchVpsPayments
? fetchDashboardInfo(apiBaseUrl, authinfo, {
fallbackCurrency,
profile,
}).catch(() => null)
: null,
fetchTariffs
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err instanceof Error ? err.message : err)
return { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> }
})
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo, profile).catch(
(err) => {
console.warn(
'fetchVdsOrderPricelistAllDatacenters failed:',
err instanceof Error ? err.message : err,
)
return {
tariffItems: [] as TariffItem[],
slist: {} as Record<string, unknown>,
}
},
)
: { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> },
])
const { tariffItems = [], slist = {} } = tariffResult || {}
@@ -95,7 +111,7 @@ export async function syncFromBillmanager(
if (fetchVpsPayments) {
for (const item of vdsItems) {
const vps = mapVdsToVps(item, providerId, accountId)
const vps = mapVdsWithProfile(profile, item, providerId, accountId)
const id = `vps-bm-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
@@ -229,7 +245,7 @@ export async function syncFromBillmanager(
)
for (const item of paymentItems) {
const payment = mapPaymentToPayment(item, accountId)
const payment = mapPaymentWithProfile(profile, item, accountId)
if (!payment || payment.amount <= 0) continue
const note = payment.note
if (existingPayments.has(note)) continue