feat(accounts): доработать управление аккаунтами хостеров
Docker / build (push) Has been cancelled

Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 16:53:51 +07:00
co-authored by Cursor
parent 06f5b27e53
commit c80090ed08
21 changed files with 844 additions and 84 deletions
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb, seedTestProvider } from '@cfdm/db/test-setup'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { buildApp } from '../index.js'
import { getSqlite } from '@cfdm/db'
describe('provider-accounts routes', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
seedTestProvider()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('creates account and returns apiLogin', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/provider-accounts',
payload: {
providerId: 'prov-1',
name: 'Primary',
apiCredentials: 'login:password',
billingMode: 'monthly',
},
})
expect(res.statusCode).toBe(201)
const body = res.json() as { apiLogin?: string; apiCredentialsSet?: boolean }
expect(body.apiLogin).toBe('login')
expect(body.apiCredentialsSet).toBe(true)
})
it('returns 409 when deleting account with VPS', async () => {
providerAccountsRepository.create({
id: 'acc-del',
providerId: 'prov-1',
name: 'Bound',
})
getSqlite()
.prepare(
`INSERT INTO vps (id, ip, providerId, providerAccountId, status) VALUES ('vps-x', '2.2.2.2', 'prov-1', 'acc-del', 'active')`,
)
.run()
const res = await app.inject({
method: 'DELETE',
url: '/api/provider-accounts/acc-del',
})
expect(res.statusCode).toBe(409)
const body = res.json() as { error?: { code?: string; dependencies?: { vps?: number } } }
expect(body.error?.code).toBe('CONFLICT')
expect(body.error?.dependencies?.vps).toBe(1)
})
it('deletes account without dependencies', async () => {
providerAccountsRepository.create({
id: 'acc-free',
providerId: 'prov-1',
name: 'Free',
})
const res = await app.inject({
method: 'DELETE',
url: '/api/provider-accounts/acc-free',
})
expect(res.statusCode).toBe(204)
expect(providerAccountsRepository.get('acc-free')).toBeUndefined()
})
})
+29 -2
View File
@@ -2,6 +2,16 @@ import type { FastifyPluginAsync } from 'fastify'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account' import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account'
function formatDependencyMessage(deps: ReturnType<typeof providerAccountsRepository.getDependencyCounts>): string {
const parts: string[] = []
if (deps.vps) parts.push(`VPS: ${deps.vps}`)
if (deps.payments) parts.push(`платежи: ${deps.payments}`)
if (deps.balanceLedger) parts.push(`журнал баланса: ${deps.balanceLedger}`)
if (deps.activeTariffs) parts.push(`тарифы: ${deps.activeTariffs}`)
if (deps.syncLog) parts.push(`записи синка: ${deps.syncLog}`)
return parts.length ? `Аккаунт используется (${parts.join(', ')})` : 'Аккаунт используется связанными записями'
}
export const providerAccountsRoutes: FastifyPluginAsync = async (app) => { export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/provider-accounts', async () => providerAccountsRepository.list()) app.get('/api/provider-accounts', async () => providerAccountsRepository.list())
@@ -27,10 +37,27 @@ export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
}) })
app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => { app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
const ok = providerAccountsRepository.delete(req.params.id) const existing = providerAccountsRepository.get(req.params.id)
if (!ok) { if (!existing) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
} }
const dependencies = providerAccountsRepository.getDependencyCounts(req.params.id)
const total =
dependencies.vps +
dependencies.payments +
dependencies.balanceLedger +
dependencies.activeTariffs +
dependencies.syncLog
if (total > 0) {
return reply.code(409).send({
error: {
code: 'CONFLICT',
message: formatDependencyMessage(dependencies),
dependencies,
},
})
}
providerAccountsRepository.delete(req.params.id)
return reply.code(204).send() return reply.code(204).send()
}) })
} }
@@ -0,0 +1,95 @@
import { SearchIcon, XIcon } from 'lucide-react'
import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { SelectField } from '@/components/select-field'
import {
type AccountFiltersState,
buildDefaultAccountFilters,
hasActiveAccountFilters,
} from '@/components/account-filters'
import { billingModeLabel } from '@/lib/format'
import type { Provider } from '@/types/entities'
interface AccountFiltersToolbarProps {
filters: AccountFiltersState
onChange: (next: AccountFiltersState) => void
providers: Provider[]
}
export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) {
const active = hasActiveAccountFilters(filters)
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Поиск по названию или логину"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
</div>
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
...filters,
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
})
}
options={[
{ value: 'monthly', label: billingModeLabel('monthly') },
{ value: 'daily', label: billingModeLabel('daily') },
]}
/>
{active ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange(buildDefaultAccountFilters())}
>
<XIcon data-icon="inline-start" />
Сбросить
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.syncableOnly}
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
/>
<span>Готовы к синку</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.issuesOnly}
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
/>
<span>С проблемами</span>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={filters.lowBalanceOnly}
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
/>
<span>Низкий баланс</span>
</label>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import type { ProviderAccount, Provider } from '@/types/entities'
import {
getAccountHealthFlags,
isAccountSyncable,
type AccountHealthContext,
} from '@/lib/account-health'
export interface AccountFiltersState {
search: string
providerIds: string[]
billingMode: '' | 'daily' | 'monthly'
syncableOnly: boolean
issuesOnly: boolean
lowBalanceOnly: boolean
}
export function buildDefaultAccountFilters(): AccountFiltersState {
return {
search: '',
providerIds: [],
billingMode: '',
syncableOnly: false,
issuesOnly: false,
lowBalanceOnly: false,
}
}
export function hasActiveAccountFilters(filters: AccountFiltersState): boolean {
return Boolean(
filters.search.trim() ||
filters.providerIds.length ||
filters.billingMode ||
filters.syncableOnly ||
filters.issuesOnly ||
filters.lowBalanceOnly,
)
}
export function applyAccountFilters(
accounts: ProviderAccount[],
filters: AccountFiltersState,
providers: Provider[],
healthCtx: AccountHealthContext,
): ProviderAccount[] {
const q = filters.search.trim().toLowerCase()
return accounts.filter((account) => {
if (q) {
const hay = `${account.name} ${account.apiLogin ?? ''}`.toLowerCase()
if (!hay.includes(q)) return false
}
if (filters.providerIds.length && !filters.providerIds.includes(account.providerId)) return false
if (filters.billingMode && (account.billingMode ?? 'monthly') !== filters.billingMode) return false
if (filters.syncableOnly && !isAccountSyncable(account, providers)) return false
const flags = getAccountHealthFlags(account, healthCtx)
if (filters.issuesOnly && flags.length === 0) return false
if (filters.lowBalanceOnly && !flags.includes('low-balance')) return false
return true
})
}
@@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { PlugIcon, RefreshCwIcon } from 'lucide-react' import { PlugIcon, RefreshCwIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
import { FormSheetRhf } from '@/components/form-sheet-rhf' import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
@@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client'
const EMPTY: ProviderAccountFormValues = { const EMPTY: ProviderAccountFormValues = {
providerId: '', providerId: '',
name: '', name: '',
login: '', apiLogin: '',
apiCredentials: '', apiPassword: '',
billingMode: 'monthly', billingMode: 'monthly',
balanceAlertBelow: '', balanceAlertBelow: '',
notes: '', notes: '',
@@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({
const providerId = watch('providerId') const providerId = watch('providerId')
const provider = providers.find((p) => p.id === providerId) const provider = providers.find((p) => p.id === providerId)
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim() const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
const creds = watch('apiCredentials')?.trim() ?? '' const apiLogin = watch('apiLogin')?.trim() ?? ''
const canTest = Boolean(apiBaseUrl && creds) const apiPassword = watch('apiPassword')?.trim() ?? ''
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && apiCredentials)
return ( return (
<> <>
@@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}> <FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} /> <Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField> </FormField>
<FormField label="Логин" htmlFor="acc-login"> <FormField label="Логин API" htmlFor="acc-login">
<Input id="acc-login" {...register('login')} /> <Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
</FormField> </FormField>
<FormField <FormField
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'} label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
htmlFor="acc-creds" htmlFor="acc-password"
description="Оставьте пустым при редактировании, чтобы сохранить существующий" description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
> >
<Input id="acc-creds" type="password" {...register('apiCredentials')} /> <Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
</FormField> </FormField>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<LoadingButton <LoadingButton
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
size="sm" size="sm"
disabled={!canTest} disabled={!canTest}
loading={testMut.isPending} loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })} onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
> >
<PlugIcon data-icon="inline-start" /> <PlugIcon data-icon="inline-start" />
Проверить подключение Проверить подключение
+103
View File
@@ -0,0 +1,103 @@
import type {
ProviderAccount,
Provider,
SyncLogRow,
BalanceLedgerRow,
} from '@/types/entities'
import { accountBalanceApi } from '@/lib/account'
import { accountBillmanagerUiReady } from '@/lib/billmanager'
import {
accountHasApiLedgerMismatch,
getStaleSyncAccountIds,
} from '@/lib/inventory-health'
export type AccountHealthFlag = 'stale-sync' | 'low-balance' | 'balance-mismatch' | 'no-creds'
export const ACCOUNT_HEALTH_LABELS: Record<AccountHealthFlag, string> = {
'stale-sync': 'Устаревший синк',
'low-balance': 'Низкий баланс',
'balance-mismatch': 'Расхождение ledger',
'no-creds': 'Нет API-доступа',
}
export interface AccountHealthContext {
providers: Provider[]
syncLog?: SyncLogRow[]
balanceLedger?: BalanceLedgerRow[]
}
export interface AtRiskAccount {
id: string
name: string
reason: string
severity: 'warning' | 'destructive'
}
export function getAccountHealthFlags(
account: ProviderAccount,
ctx: AccountHealthContext,
): AccountHealthFlag[] {
const provider = ctx.providers.find((p) => p.id === account.providerId)
const flags: AccountHealthFlag[] = []
if (provider?.apiType === 'billmanager' && !account.apiCredentialsSet) {
flags.push('no-creds')
}
const staleIds = new Set(getStaleSyncAccountIds([account], ctx.providers, ctx.syncLog ?? []))
if (staleIds.has(account.id)) flags.push('stale-sync')
const ext = account as ProviderAccount & { balanceAlertBelow?: number | null }
const threshold = Number(ext.balanceAlertBelow ?? 0)
const balance = accountBalanceApi(account)
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
flags.push('low-balance')
}
if (ctx.balanceLedger && accountHasApiLedgerMismatch(account, ctx.balanceLedger)) {
flags.push('balance-mismatch')
}
return flags
}
export function accountHasHealthIssues(account: ProviderAccount, ctx: AccountHealthContext): boolean {
return getAccountHealthFlags(account, ctx).length > 0
}
export function buildAtRiskAccounts(
accounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
): AtRiskAccount[] {
const ctx: AccountHealthContext = { providers, syncLog }
const rows: AtRiskAccount[] = []
for (const a of accounts) {
const flags = getAccountHealthFlags(a, ctx)
if (flags.includes('low-balance')) {
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['low-balance'], severity: 'destructive' })
} else if (flags.includes('stale-sync')) {
rows.push({ id: a.id, name: a.name, reason: ACCOUNT_HEALTH_LABELS['stale-sync'], severity: 'warning' })
}
}
return rows
}
export function countAccountsWithIssues(
accounts: ProviderAccount[],
ctx: AccountHealthContext,
): number {
return accounts.filter((a) => accountHasHealthIssues(a, ctx)).length
}
export function countLowBalanceAccounts(
accounts: ProviderAccount[],
ctx: AccountHealthContext,
): number {
return accounts.filter((a) => getAccountHealthFlags(a, ctx).includes('low-balance')).length
}
export function isAccountSyncable(account: ProviderAccount, providers: Provider[]): boolean {
const provider = providers.find((p) => p.id === account.providerId)
return accountBillmanagerUiReady(account, provider)
}
+5 -2
View File
@@ -29,8 +29,11 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (!res.ok) { if (!res.ok) {
let message = res.statusText || 'API error' let message = res.statusText || 'API error'
try { try {
const data = (await res.json()) as { error?: string } const data = (await res.json()) as {
if (data?.error) message = data.error error?: string | { message?: string; code?: string }
}
if (typeof data?.error === 'string') message = data.error
else if (data?.error?.message) message = data.error.message
} catch { } catch {
/* ignore */ /* ignore */
} }
+16
View File
@@ -96,6 +96,22 @@ export function billingModeLabel(mode: string): string {
return BILLING_MODE_LABELS[mode] ?? mode return BILLING_MODE_LABELS[mode] ?? mode
} }
/** Относительное время для дат синка и обновлений. */
export function formatRelativeTime(isoOrMs: string | number | null | undefined): string {
if (isoOrMs == null) return '—'
const t = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime()
if (Number.isNaN(t)) return '—'
const diffMs = Date.now() - t
if (diffMs < 0) return 'только что'
const mins = Math.floor(diffMs / 60_000)
if (mins < 1) return 'только что'
if (mins < 60) return `${mins} мин назад`
const hours = Math.floor(mins / 60)
if (hours < 48) return `${hours} ч назад`
const days = Math.floor(hours / 24)
return `${days} дн назад`
}
const TARIFF_TYPE_LABELS: Record<string, string> = { const TARIFF_TYPE_LABELS: Record<string, string> = {
daily: 'Суточный', monthly: 'Месячный', daily: 'Суточный', monthly: 'Месячный',
} }
+4 -3
View File
@@ -1,8 +1,9 @@
import { z } from 'zod' import { z } from 'zod'
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived']) export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
export const tariffTypeSchema = z.enum(['daily', 'monthly']) export const tariffTypeSchema = z.enum(['daily', 'monthly'])
export const billingModeSchema = z.enum(['daily', 'monthly']) export const billingModeSchema = sharedBillingModeSchema
export const paymentTypeSchema = z.enum([ export const paymentTypeSchema = z.enum([
'direct_vps_payment', 'direct_vps_payment',
'provider_balance_topup', 'provider_balance_topup',
@@ -30,8 +31,8 @@ export const providerAccountSchema = z.object({
id: z.string().min(1).optional(), id: z.string().min(1).optional(),
providerId: z.string().min(1, 'Выберите хостера'), providerId: z.string().min(1, 'Выберите хостера'),
name: z.string().min(1, 'Название обязательно'), name: z.string().min(1, 'Название обязательно'),
login: z.string().optional().default(''), apiLogin: z.string().optional().default(''),
apiCredentials: z.string().optional().default(''), apiPassword: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'), billingMode: billingModeSchema.default('monthly'),
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(''),
+161 -26
View File
@@ -9,9 +9,13 @@ import {
PlugIcon, PlugIcon,
ReceiptIcon, ReceiptIcon,
WalletIcon, WalletIcon,
ActivityIcon,
ClockIcon,
ServerIcon,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { z } from 'zod' import { z } from 'zod'
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client' import { api, ApiError } from '@/lib/api-client'
@@ -23,6 +27,7 @@ import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page' import { CrudListPage } from '@/components/crud-list-page'
import { RowActions } from '@/components/row-actions' import { RowActions } from '@/components/row-actions'
import { HealthModeBanner } from '@/components/health-mode-banner' import { HealthModeBanner } from '@/components/health-mode-banner'
import { SectionCards } from '@/components/section-cards'
import { import {
ProviderAccountEditSheet, ProviderAccountEditSheet,
providerAccountFormDefaults, providerAccountFormDefaults,
@@ -31,16 +36,38 @@ import type { ProviderAccountFormValues } from '@/lib/schemas'
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account' import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
import type { ProviderAccount } from '@/types/entities' import type { ProviderAccount } from '@/types/entities'
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager' import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
import { billingModeLabel, formatCurrency } from '@/lib/format' import { billingModeLabel, formatCurrency, formatRelativeTime } from '@/lib/format'
import { import {
getBalanceMismatchAccountIds, getBalanceMismatchAccountIds,
getStaleSyncAccountIds, getStaleSyncAccountIds,
lastOkSyncFinishedAt,
} from '@/lib/inventory-health' } from '@/lib/inventory-health'
import {
ACCOUNT_HEALTH_LABELS,
buildAtRiskAccounts,
countAccountsWithIssues,
countLowBalanceAccounts,
getAccountHealthFlags,
type AccountHealthFlag,
} from '@/lib/account-health'
import {
applyAccountFilters,
buildDefaultAccountFilters,
type AccountFiltersState,
} from '@/components/account-filters'
import { AccountFiltersToolbar } from '@/components/account-filters-toolbar'
const accountsSearchSchema = z.object({ const accountsSearchSchema = z.object({
health: z.string().optional(), health: z.string().optional(),
}) })
const HEALTH_BADGE_VARIANT: Record<AccountHealthFlag, 'default' | 'secondary' | 'destructive' | 'outline'> = {
'stale-sync': 'secondary',
'low-balance': 'destructive',
'balance-mismatch': 'outline',
'no-creds': 'outline',
}
export const Route = createFileRoute('/_auth/accounts')({ export const Route = createFileRoute('/_auth/accounts')({
validateSearch: (search) => accountsSearchSchema.parse(search), validateSearch: (search) => accountsSearchSchema.parse(search),
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
@@ -48,25 +75,31 @@ export const Route = createFileRoute('/_auth/accounts')({
component: AccountsPage, component: AccountsPage,
}) })
function buildSavePayload(r: ProviderAccountFormValues) {
const { apiLogin, apiPassword, balanceAlertBelow, ...rest } = r
const alertRaw = balanceAlertBelow === '' || balanceAlertBelow == null ? '' : String(balanceAlertBelow)
const alertNum = alertRaw ? Number(alertRaw) : null
const base = {
...rest,
balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null,
}
const creds = buildApiCredentials(apiLogin ?? '', apiPassword ?? '')
return creds ? { ...base, apiCredentials: creds } : base
}
function AccountsPage() { function AccountsPage() {
const { health } = Route.useSearch() const { health } = Route.useSearch()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<AccountFiltersState>(buildDefaultAccountFilters())
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>( const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''), providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''),
) )
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: (r: ProviderAccountFormValues) => { mutationFn: (r: ProviderAccountFormValues) => {
const { apiCredentials, balanceAlertBelow, ...rest } = r const payload = buildSavePayload(r)
const alertRaw = balanceAlertBelow === '' || balanceAlertBelow == null ? '' : String(balanceAlertBelow)
const alertNum = alertRaw ? Number(alertRaw) : null
const base = {
...rest,
balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null,
}
const payload = apiCredentials ? { ...base, apiCredentials } : base
return r.id return r.id
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>) ? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
: api.create('providerAccounts', payload as unknown as ProviderAccount) : api.create('providerAccounts', payload as unknown as ProviderAccount)
@@ -126,8 +159,8 @@ function AccountsPage() {
id: a.id, id: a.id,
providerId: a.providerId, providerId: a.providerId,
name: a.name, name: a.name,
login: a.login ?? '', apiLogin: a.apiLogin ?? a.login ?? '',
apiCredentials: '', apiPassword: '',
billingMode: a.billingMode ?? 'monthly', billingMode: a.billingMode ?? 'monthly',
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '', balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
notes: a.notes ?? '', notes: a.notes ?? '',
@@ -141,21 +174,70 @@ function AccountsPage() {
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length ? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
: 0 : 0
const healthCtx = useMemo(
() => ({
providers: snapshot?.providers ?? [],
syncLog: snapshot?.syncLog ?? [],
balanceLedger: snapshot?.balanceLedger ?? [],
}),
[snapshot],
)
const vpsCountByAccount = useMemo(() => {
const map = new Map<string, number>()
for (const v of snapshot?.vps ?? []) {
if (!v.providerAccountId) continue
map.set(v.providerAccountId, (map.get(v.providerAccountId) ?? 0) + 1)
}
return map
}, [snapshot?.vps])
const filteredAccounts = useMemo(() => { const filteredAccounts = useMemo(() => {
const accounts = snapshot?.providerAccounts ?? [] const accounts = snapshot?.providerAccounts ?? []
if (!health || !snapshot) return accounts let result = accounts
if (health === 'stale-sync') { if (health && snapshot) {
const ids = new Set( if (health === 'stale-sync') {
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []), const ids = new Set(
) getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
return accounts.filter((a) => ids.has(a.id)) )
result = accounts.filter((a) => ids.has(a.id))
} else if (health === 'balance-mismatch') {
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger))
result = accounts.filter((a) => ids.has(a.id))
}
} }
if (health === 'balance-mismatch') { return applyAccountFilters(result, filters, snapshot?.providers ?? [], healthCtx)
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger)) }, [snapshot, health, filters, healthCtx])
return accounts.filter((a) => ids.has(a.id))
} const summaryCards = useMemo(() => {
return accounts if (!snapshot) return []
}, [snapshot, health]) const accounts = snapshot.providerAccounts
const atRisk = buildAtRiskAccounts(accounts, snapshot.providers, snapshot.syncLog ?? [])
return [
{
label: 'Всего аккаунтов',
value: accounts.length,
onClick: () => setFilters(buildDefaultAccountFilters()),
},
{
label: 'Готовы к синку',
value: syncableCount,
onClick: () => setFilters({ ...buildDefaultAccountFilters(), syncableOnly: true }),
},
{
label: 'С проблемами',
value: countAccountsWithIssues(accounts, healthCtx),
variant: atRisk.length ? ('warning' as const) : ('default' as const),
onClick: () => setFilters({ ...buildDefaultAccountFilters(), issuesOnly: true }),
},
{
label: 'Низкий баланс',
value: countLowBalanceAccounts(accounts, healthCtx),
variant: countLowBalanceAccounts(accounts, healthCtx) ? ('destructive' as const) : ('default' as const),
onClick: () => setFilters({ ...buildDefaultAccountFilters(), lowBalanceOnly: true }),
},
]
}, [snapshot, syncableCount, healthCtx])
const columns: DataTableColumn<ProviderAccount>[] = [ const columns: DataTableColumn<ProviderAccount>[] = [
{ {
@@ -168,7 +250,30 @@ function AccountsPage() {
key: 'login', key: 'login',
header: 'Логин', header: 'Логин',
icon: KeyRoundIcon, icon: KeyRoundIcon,
cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span>, cell: (a) => (
<span className="text-muted-foreground">{a.apiLogin ?? a.login ?? '—'}</span>
),
},
{
key: 'health',
header: 'Статус',
icon: ActivityIcon,
sortable: false,
cell: (a) => {
const flags = getAccountHealthFlags(a, healthCtx)
if (!flags.length) {
return <Badge variant="outline">OK</Badge>
}
return (
<div className="flex flex-wrap gap-1">
{flags.map((flag) => (
<Badge key={flag} variant={HEALTH_BADGE_VARIANT[flag]}>
{ACCOUNT_HEALTH_LABELS[flag]}
</Badge>
))}
</div>
)
},
}, },
{ {
key: 'creds', key: 'creds',
@@ -186,6 +291,28 @@ function AccountsPage() {
icon: ReceiptIcon, icon: ReceiptIcon,
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>, cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
}, },
{
key: 'vps',
header: 'VPS',
icon: ServerIcon,
headerClassName: 'text-right',
className: 'text-right tabular-nums',
sortValue: (a) => vpsCountByAccount.get(a.id) ?? 0,
cell: (a) => {
const count = vpsCountByAccount.get(a.id) ?? 0
return count ? <Badge variant="secondary">{count}</Badge> : <span className="text-muted-foreground">0</span>
},
},
{
key: 'sync',
header: 'Последний синк',
icon: ClockIcon,
sortValue: (a) => lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? []) ?? 0,
cell: (a) => {
const t = lastOkSyncFinishedAt(a.id, snapshot?.syncLog ?? [])
return <span className="text-muted-foreground">{formatRelativeTime(t)}</span>
},
},
{ {
key: 'balance', key: 'balance',
header: 'Баланс (API)', header: 'Баланс (API)',
@@ -283,15 +410,23 @@ function AccountsPage() {
) : null ) : null
} }
> >
{( ) => ( {() => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{snapshot ? <SectionCards items={summaryCards} /> : null}
{snapshot ? (
<AccountFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snapshot.providers}
/>
) : null}
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null} {health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
<DataGridCard <DataGridCard
columns={columnDefFromDataTable(columns)} columns={columnDefFromDataTable(columns)}
data={filteredAccounts} data={filteredAccounts}
rowId={(a) => a.id} rowId={(a) => a.id}
pinLastColumn pinLastColumn
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'} emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
/> />
</div> </div>
)} )}
+3 -30
View File
@@ -33,13 +33,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/ta
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health' import { computeInventoryHealth } from '@/lib/inventory-health'
import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health'
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format' import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
import { accountBalanceApi } from '@/lib/account'
import { exportActiveVpsCsv } from '@/lib/export-csv' import { exportActiveVpsCsv } from '@/lib/export-csv'
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts' import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities' import type { Vps } from '@/types/entities'
const DASHBOARD_TAB_TRIGGER_CLASS = const DASHBOARD_TAB_TRIGGER_CLASS =
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent' 'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
@@ -55,33 +55,6 @@ export const Route = createFileRoute('/_auth/dashboard')({
type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string } type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string }
interface AtRiskAccount {
id: string
name: string
reason: string
severity: 'warning' | 'destructive'
}
function buildAtRiskAccounts(
accounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
): AtRiskAccount[] {
const staleIds = new Set(getStaleSyncAccountIds(accounts, providers, syncLog))
const rows: AtRiskAccount[] = []
for (const a of accounts) {
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
const threshold = Number(ext.balanceAlertBelow ?? 0)
const balance = accountBalanceApi(a)
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
rows.push({ id: a.id, name: a.name, reason: 'Низкий баланс', severity: 'destructive' })
} else if (staleIds.has(a.id)) {
rows.push({ id: a.id, name: a.name, reason: 'Устаревший синк', severity: 'warning' })
}
}
return rows
}
function DashboardPage() { function DashboardPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
+2
View File
@@ -27,6 +27,8 @@ export interface ProviderAccount {
id: string id: string
providerId: string providerId: string
name: string name: string
apiLogin?: string
/** @deprecated используйте apiLogin */
login?: string login?: string
apiCredentialsSet?: boolean apiCredentialsSet?: boolean
billingMode?: BillingMode billingMode?: BillingMode
+10 -3
View File
@@ -17,19 +17,26 @@
"./repositories/*": { "./repositories/*": {
"types": "./dist/repositories/*.d.ts", "types": "./dist/repositories/*.d.ts",
"default": "./dist/repositories/*.js" "default": "./dist/repositories/*.js"
},
"./test-setup": {
"types": "./dist/test-setup.d.ts",
"default": "./dist/test-setup.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.40.0", "drizzle-orm": "^0.40.0",
"better-sqlite3": "^11.10.0" "better-sqlite3": "^11.10.0",
"@cfdm/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"drizzle-kit": "^0.30.0", "drizzle-kit": "^0.30.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"typescript": "^5.9.2" "typescript": "^5.9.2",
"vitest": "^3.0.0"
} }
} }
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { parseApiLogin } from '@cfdm/shared/utils/api-credentials'
import { providerAccountsRepository } from './provider-accounts.js'
import { resetTestDb, seedTestProvider } from '../test-setup.js'
import { getSqlite } from '../index.js'
describe('parseApiLogin', () => {
it('extracts login before colon', () => {
expect(parseApiLogin('user:secret')).toBe('user')
expect(parseApiLogin(' admin:pass ')).toBe('admin')
})
it('returns empty for invalid credentials', () => {
expect(parseApiLogin('')).toBe('')
expect(parseApiLogin('nocolon')).toBe('')
expect(parseApiLogin(':onlypass')).toBe('')
})
})
describe('providerAccountsRepository', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
})
it('returns apiLogin without exposing password', () => {
const created = providerAccountsRepository.create({
providerId: 'prov-1',
name: 'Main',
apiCredentials: 'apiuser:apipass',
})
expect(created.apiLogin).toBe('apiuser')
expect(created.apiCredentialsSet).toBe(true)
expect('apiCredentials' in created).toBe(false)
})
it('preserves credentials on update when apiCredentials empty', () => {
providerAccountsRepository.create({
id: 'acc-1',
providerId: 'prov-1',
name: 'Main',
apiCredentials: 'keep:me',
})
const updated = providerAccountsRepository.update('acc-1', { name: 'Renamed', apiCredentials: '' })
expect(updated?.name).toBe('Renamed')
expect(updated?.apiLogin).toBe('keep')
})
it('counts dependencies before delete', () => {
providerAccountsRepository.create({
id: 'acc-1',
providerId: 'prov-1',
name: 'Main',
})
getSqlite()
.prepare(`INSERT INTO vps (id, ip, providerId, providerAccountId, status) VALUES ('vps-1', '1.1.1.1', 'prov-1', 'acc-1', 'active')`)
.run()
const deps = providerAccountsRepository.getDependencyCounts('acc-1')
expect(deps.vps).toBe(1)
expect(deps.payments).toBe(0)
})
})
@@ -1,4 +1,5 @@
import { asc, eq } from 'drizzle-orm' import { asc, count, eq } from 'drizzle-orm'
import { parseApiLogin } from '@cfdm/shared'
import { getDb, schema } from '../index.js' import { getDb, schema } from '../index.js'
import { generateId } from './utils.js' import { generateId } from './utils.js'
@@ -10,12 +11,25 @@ type AccountInsert = Partial<typeof schema.providerAccounts.$inferInsert> & {
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> { export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
apiCredentialsSet: boolean apiCredentialsSet: boolean
apiLogin: string
}
export interface AccountDependencyCounts {
vps: number
payments: number
balanceLedger: number
activeTariffs: number
syncLog: number
} }
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined { function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
if (!row) return undefined if (!row) return undefined
const { apiCredentials, ...rest } = row const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) } return {
...rest,
apiCredentialsSet: Boolean(apiCredentials),
apiLogin: parseApiLogin(apiCredentials),
}
} }
function normalize(input: Partial<AccountRow>) { function normalize(input: Partial<AccountRow>) {
@@ -35,6 +49,36 @@ function normalize(input: Partial<AccountRow>) {
} }
} }
function countVpsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.vps).where(eq(schema.vps.providerAccountId, id)).get()?.count ?? 0,
)
}
function countPaymentsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.payments).where(eq(schema.payments.providerAccountId, id)).get()?.count ?? 0,
)
}
function countLedgerForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.balanceLedger).where(eq(schema.balanceLedger.providerAccountId, id)).get()?.count ?? 0,
)
}
function countTariffsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.activeTariffs).where(eq(schema.activeTariffs.providerAccountId, id)).get()?.count ?? 0,
)
}
function countSyncLogForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.syncLog).where(eq(schema.syncLog.accountId, id)).get()?.count ?? 0,
)
}
export const providerAccountsRepository = { export const providerAccountsRepository = {
list(): PublicAccountRow[] { list(): PublicAccountRow[] {
const rows = getDb() const rows = getDb()
@@ -62,6 +106,16 @@ export const providerAccountsRepository = {
.get() .get()
}, },
getDependencyCounts(id: string): AccountDependencyCounts {
return {
vps: countVpsForAccount(id),
payments: countPaymentsForAccount(id),
balanceLedger: countLedgerForAccount(id),
activeTariffs: countTariffsForAccount(id),
syncLog: countSyncLogForAccount(id),
}
},
create(input: AccountInsert, id?: string): PublicAccountRow { create(input: AccountInsert, id?: string): PublicAccountRow {
const db = getDb() const db = getDb()
const finalId = id ?? input.id ?? generateId('account') const finalId = id ?? input.id ?? generateId('account')
@@ -76,8 +130,8 @@ export const providerAccountsRepository = {
const existing = this.getWithCredentials(id) const existing = this.getWithCredentials(id)
if (!existing) return undefined if (!existing) return undefined
const apiCredentials = const apiCredentials =
input.apiCredentials !== undefined input.apiCredentials !== undefined && String(input.apiCredentials || '').trim() !== ''
? String(input.apiCredentials || '') ? String(input.apiCredentials)
: (existing.apiCredentials || '') : (existing.apiCredentials || '')
let balanceAlertBelow = existing.balanceAlertBelow let balanceAlertBelow = existing.balanceAlertBelow
@@ -112,3 +166,5 @@ export const providerAccountsRepository = {
return res.changes > 0 return res.changes > 0
}, },
} }
export { parseApiLogin }
+104
View File
@@ -0,0 +1,104 @@
import { closeDb, getSqlite } from './index.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
website TEXT,
contact TEXT,
baseCurrency TEXT,
usdRate TEXT,
eurRate TEXT,
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT
);
CREATE TABLE IF NOT EXISTS provider_accounts (
id TEXT PRIMARY KEY,
providerId TEXT NOT NULL,
name TEXT NOT NULL,
panelUrl TEXT,
currency TEXT,
billingMode TEXT,
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT,
apiCredentials TEXT,
balance_api REAL,
balance_currency TEXT,
balance_updated_at TEXT,
enoughmoneyto TEXT,
balance_alert_below REAL,
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
ip TEXT,
providerId TEXT,
providerAccountId TEXT,
status TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id),
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS balance_ledger (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
direction TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
accountId TEXT NOT NULL,
startedAt TEXT NOT NULL,
finishedAt TEXT,
status TEXT,
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS active_tariffs (
id TEXT PRIMARY KEY,
providerAccountId TEXT NOT NULL,
providerId TEXT NOT NULL,
externalId TEXT NOT NULL,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (providerId) REFERENCES providers(id)
);
`
export function resetTestDb(): void {
closeDb()
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)
}
export function seedTestProvider(id = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES (?, 'Test Host', 'billmanager', 'https://bm.test')`,
)
.run(id)
}
+4
View File
@@ -25,6 +25,10 @@
"./geo/*": { "./geo/*": {
"types": "./dist/geo/*.d.ts", "types": "./dist/geo/*.d.ts",
"default": "./dist/geo/*.js" "default": "./dist/geo/*.js"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"default": "./dist/utils/*.js"
} }
}, },
"scripts": { "scripts": {
@@ -1,15 +1,29 @@
import { z } from 'zod' import { z } from 'zod'
export const providerAccountSchema = z.object({ export const billingModeSchema = z.enum(['daily', 'monthly'])
export const providerAccountInputSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
providerId: z.string().min(1, 'Provider is required'), providerId: z.string().min(1, 'Provider is required'),
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
panelUrl: z.string().optional().default(''), panelUrl: z.string().optional().default(''),
currency: z.string().optional().default(''), currency: z.string().optional().default(''),
billingMode: z.string().optional().default(''), billingMode: billingModeSchema.optional().default('monthly'),
notes: z.string().optional().default(''), notes: z.string().optional().default(''),
apiCredentials: z.string().optional().default(''), apiCredentials: z.string().optional().default(''),
balanceAlertBelow: z.union([z.number(), z.null()]).optional(), balanceAlertBelow: z.union([z.number(), z.null()]).optional(),
}) })
export type ProviderAccount = z.infer<typeof providerAccountSchema> export const providerAccountPublicSchema = providerAccountInputSchema
.omit({ apiCredentials: true })
.extend({
apiCredentialsSet: z.boolean().optional(),
apiLogin: z.string().optional(),
})
/** @deprecated Используйте providerAccountInputSchema */
export const providerAccountSchema = providerAccountInputSchema
export type ProviderAccountInput = z.infer<typeof providerAccountInputSchema>
export type ProviderAccountPublic = z.infer<typeof providerAccountPublicSchema>
export type ProviderAccount = ProviderAccountInput
+1
View File
@@ -1,5 +1,6 @@
export * from './contracts/provider.js' export * from './contracts/provider.js'
export * from './contracts/provider-account.js' export * from './contracts/provider-account.js'
export * from './utils/api-credentials.js'
export * from './contracts/vps.js' export * from './contracts/vps.js'
export * from './contracts/payment.js' export * from './contracts/payment.js'
export * from './contracts/balance-ledger.js' export * from './contracts/balance-ledger.js'
@@ -0,0 +1,15 @@
/** Логин из BILLmanager-кредов формата `login:password`. */
export function parseApiLogin(credentials: string | null | undefined): string {
const cred = String(credentials ?? '').trim()
const idx = cred.indexOf(':')
return idx > 0 ? cred.slice(0, idx) : ''
}
/** Собрать креды для API из отдельных полей формы. */
export function buildApiCredentials(login: string, password: string): string {
const l = login.trim()
const p = password
if (!l && !p) return ''
if (!l) return p
return p ? `${l}:${p}` : ''
}
+6
View File
@@ -199,6 +199,9 @@ importers:
packages/db: packages/db:
dependencies: dependencies:
'@cfdm/shared':
specifier: workspace:*
version: link:../shared
better-sqlite3: better-sqlite3:
specifier: ^11.10.0 specifier: ^11.10.0
version: 11.10.0 version: 11.10.0
@@ -218,6 +221,9 @@ importers:
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.3 version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
packages/shared: packages/shared:
dependencies: dependencies: