Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,16 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
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) => {
|
||||
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) => {
|
||||
const ok = providerAccountsRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
const existing = providerAccountsRepository.get(req.params.id)
|
||||
if (!existing) {
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 { PlugIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -17,8 +18,8 @@ import { api, ApiError } from '@/lib/api-client'
|
||||
const EMPTY: ProviderAccountFormValues = {
|
||||
providerId: '',
|
||||
name: '',
|
||||
login: '',
|
||||
apiCredentials: '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
billingMode: 'monthly',
|
||||
balanceAlertBelow: '',
|
||||
notes: '',
|
||||
@@ -91,8 +92,10 @@ export function ProviderAccountEditSheet({
|
||||
const providerId = watch('providerId')
|
||||
const provider = providers.find((p) => p.id === providerId)
|
||||
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
|
||||
const creds = watch('apiCredentials')?.trim() ?? ''
|
||||
const canTest = Boolean(apiBaseUrl && creds)
|
||||
const apiLogin = watch('apiLogin')?.trim() ?? ''
|
||||
const apiPassword = watch('apiPassword')?.trim() ?? ''
|
||||
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
|
||||
const canTest = Boolean(apiBaseUrl && apiCredentials)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -108,15 +111,15 @@ export function ProviderAccountEditSheet({
|
||||
<FormField label="Название" htmlFor="acc-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||
<Input id="acc-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||
</FormField>
|
||||
<FormField label="Логин" htmlFor="acc-login">
|
||||
<Input id="acc-login" {...register('login')} />
|
||||
<FormField label="Логин API" htmlFor="acc-login">
|
||||
<Input id="acc-login" autoComplete="off" {...register('apiLogin')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={isEdit ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
|
||||
htmlFor="acc-creds"
|
||||
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
|
||||
label={isEdit ? 'Пароль API (необязательно)' : 'Пароль API'}
|
||||
htmlFor="acc-password"
|
||||
description={isEdit ? 'Оставьте пустым, чтобы сохранить существующий пароль' : undefined}
|
||||
>
|
||||
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
||||
<Input id="acc-password" type="password" autoComplete="new-password" {...register('apiPassword')} />
|
||||
</FormField>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
@@ -125,7 +128,7 @@ export function ProviderAccountEditSheet({
|
||||
size="sm"
|
||||
disabled={!canTest}
|
||||
loading={testMut.isPending}
|
||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
|
||||
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials })}
|
||||
>
|
||||
<PlugIcon data-icon="inline-start" />
|
||||
Проверить подключение
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -29,8 +29,11 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'API error'
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
if (data?.error) message = data.error
|
||||
const data = (await res.json()) as {
|
||||
error?: string | { message?: string; code?: string }
|
||||
}
|
||||
if (typeof data?.error === 'string') message = data.error
|
||||
else if (data?.error?.message) message = data.error.message
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -96,6 +96,22 @@ export function billingModeLabel(mode: string): string {
|
||||
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> = {
|
||||
daily: 'Суточный', monthly: 'Месячный',
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = sharedBillingModeSchema
|
||||
export const paymentTypeSchema = z.enum([
|
||||
'direct_vps_payment',
|
||||
'provider_balance_topup',
|
||||
@@ -30,8 +31,8 @@ export const providerAccountSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
login: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
apiLogin: z.string().optional().default(''),
|
||||
apiPassword: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.default('monthly'),
|
||||
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
|
||||
notes: z.string().optional().default(''),
|
||||
|
||||
@@ -9,9 +9,13 @@ import {
|
||||
PlugIcon,
|
||||
ReceiptIcon,
|
||||
WalletIcon,
|
||||
ActivityIcon,
|
||||
ClockIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
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 { RowActions } from '@/components/row-actions'
|
||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import {
|
||||
ProviderAccountEditSheet,
|
||||
providerAccountFormDefaults,
|
||||
@@ -31,16 +36,38 @@ import type { ProviderAccountFormValues } from '@/lib/schemas'
|
||||
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
||||
import type { ProviderAccount } from '@/types/entities'
|
||||
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
||||
import { billingModeLabel, formatCurrency, formatRelativeTime } from '@/lib/format'
|
||||
import {
|
||||
getBalanceMismatchAccountIds,
|
||||
getStaleSyncAccountIds,
|
||||
lastOkSyncFinishedAt,
|
||||
} 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({
|
||||
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')({
|
||||
validateSearch: (search) => accountsSearchSchema.parse(search),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -48,25 +75,31 @@ export const Route = createFileRoute('/_auth/accounts')({
|
||||
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() {
|
||||
const { health } = Route.useSearch()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<AccountFiltersState>(buildDefaultAccountFilters())
|
||||
const [formDefaults, setFormDefaults] = useState<ProviderAccountFormValues>(
|
||||
providerAccountFormDefaults(null, snapshot?.providers[0]?.id ?? ''),
|
||||
)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: ProviderAccountFormValues) => {
|
||||
const { apiCredentials, 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 payload = apiCredentials ? { ...base, apiCredentials } : base
|
||||
const payload = buildSavePayload(r)
|
||||
return r.id
|
||||
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
|
||||
: api.create('providerAccounts', payload as unknown as ProviderAccount)
|
||||
@@ -126,8 +159,8 @@ function AccountsPage() {
|
||||
id: a.id,
|
||||
providerId: a.providerId,
|
||||
name: a.name,
|
||||
login: a.login ?? '',
|
||||
apiCredentials: '',
|
||||
apiLogin: a.apiLogin ?? a.login ?? '',
|
||||
apiPassword: '',
|
||||
billingMode: a.billingMode ?? 'monthly',
|
||||
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
|
||||
notes: a.notes ?? '',
|
||||
@@ -141,21 +174,70 @@ function AccountsPage() {
|
||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||
: 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 accounts = snapshot?.providerAccounts ?? []
|
||||
if (!health || !snapshot) return accounts
|
||||
if (health === 'stale-sync') {
|
||||
const ids = new Set(
|
||||
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
||||
)
|
||||
return accounts.filter((a) => ids.has(a.id))
|
||||
let result = accounts
|
||||
if (health && snapshot) {
|
||||
if (health === 'stale-sync') {
|
||||
const ids = new Set(
|
||||
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
||||
)
|
||||
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') {
|
||||
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger))
|
||||
return accounts.filter((a) => ids.has(a.id))
|
||||
}
|
||||
return accounts
|
||||
}, [snapshot, health])
|
||||
return applyAccountFilters(result, filters, snapshot?.providers ?? [], healthCtx)
|
||||
}, [snapshot, health, filters, healthCtx])
|
||||
|
||||
const summaryCards = useMemo(() => {
|
||||
if (!snapshot) return []
|
||||
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>[] = [
|
||||
{
|
||||
@@ -168,7 +250,30 @@ function AccountsPage() {
|
||||
key: 'login',
|
||||
header: 'Логин',
|
||||
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',
|
||||
@@ -186,6 +291,28 @@ function AccountsPage() {
|
||||
icon: ReceiptIcon,
|
||||
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',
|
||||
header: 'Баланс (API)',
|
||||
@@ -283,15 +410,23 @@ function AccountsPage() {
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{( ) => (
|
||||
{() => (
|
||||
<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}
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={filteredAccounts}
|
||||
rowId={(a) => a.id}
|
||||
pinLastColumn
|
||||
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'}
|
||||
emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,13 +33,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/ta
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
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 { accountBalanceApi } from '@/lib/account'
|
||||
import { exportActiveVpsCsv } from '@/lib/export-csv'
|
||||
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 =
|
||||
'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 }
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ProviderAccount {
|
||||
id: string
|
||||
providerId: string
|
||||
name: string
|
||||
apiLogin?: string
|
||||
/** @deprecated используйте apiLogin */
|
||||
login?: string
|
||||
apiCredentialsSet?: boolean
|
||||
billingMode?: BillingMode
|
||||
|
||||
@@ -17,19 +17,26 @@
|
||||
"./repositories/*": {
|
||||
"types": "./dist/repositories/*.d.ts",
|
||||
"default": "./dist/repositories/*.js"
|
||||
},
|
||||
"./test-setup": {
|
||||
"types": "./dist/test-setup.d.ts",
|
||||
"default": "./dist/test-setup.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"better-sqlite3": "^11.10.0"
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"@cfdm/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@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 { generateId } from './utils.js'
|
||||
|
||||
@@ -10,12 +11,25 @@ type AccountInsert = Partial<typeof schema.providerAccounts.$inferInsert> & {
|
||||
|
||||
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
|
||||
apiCredentialsSet: boolean
|
||||
apiLogin: string
|
||||
}
|
||||
|
||||
export interface AccountDependencyCounts {
|
||||
vps: number
|
||||
payments: number
|
||||
balanceLedger: number
|
||||
activeTariffs: number
|
||||
syncLog: number
|
||||
}
|
||||
|
||||
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
|
||||
if (!row) return undefined
|
||||
const { apiCredentials, ...rest } = row
|
||||
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
||||
return {
|
||||
...rest,
|
||||
apiCredentialsSet: Boolean(apiCredentials),
|
||||
apiLogin: parseApiLogin(apiCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
list(): PublicAccountRow[] {
|
||||
const rows = getDb()
|
||||
@@ -62,6 +106,16 @@ export const providerAccountsRepository = {
|
||||
.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 {
|
||||
const db = getDb()
|
||||
const finalId = id ?? input.id ?? generateId('account')
|
||||
@@ -76,8 +130,8 @@ export const providerAccountsRepository = {
|
||||
const existing = this.getWithCredentials(id)
|
||||
if (!existing) return undefined
|
||||
const apiCredentials =
|
||||
input.apiCredentials !== undefined
|
||||
? String(input.apiCredentials || '')
|
||||
input.apiCredentials !== undefined && String(input.apiCredentials || '').trim() !== ''
|
||||
? String(input.apiCredentials)
|
||||
: (existing.apiCredentials || '')
|
||||
|
||||
let balanceAlertBelow = existing.balanceAlertBelow
|
||||
@@ -112,3 +166,5 @@ export const providerAccountsRepository = {
|
||||
return res.changes > 0
|
||||
},
|
||||
}
|
||||
|
||||
export { parseApiLogin }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
"./geo/*": {
|
||||
"types": "./dist/geo/*.d.ts",
|
||||
"default": "./dist/geo/*.js"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./dist/utils/*.d.ts",
|
||||
"default": "./dist/utils/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
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(),
|
||||
providerId: z.string().min(1, 'Provider is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
panelUrl: z.string().optional().default(''),
|
||||
currency: z.string().optional().default(''),
|
||||
billingMode: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.optional().default('monthly'),
|
||||
notes: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
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,5 +1,6 @@
|
||||
export * from './contracts/provider.js'
|
||||
export * from './contracts/provider-account.js'
|
||||
export * from './utils/api-credentials.js'
|
||||
export * from './contracts/vps.js'
|
||||
export * from './contracts/payment.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}` : ''
|
||||
}
|
||||
Generated
+6
@@ -199,6 +199,9 @@ importers:
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
'@cfdm/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
better-sqlite3:
|
||||
specifier: ^11.10.0
|
||||
version: 11.10.0
|
||||
@@ -218,6 +221,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.6(@types/node@22.20.0)(happy-dom@18.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user