Update manifest.json and infra README
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes. - Added new files related to MCC categories and optional hold reasons in the manifest. - Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
This commit is contained in:
@@ -218,16 +218,19 @@ export interface CategoryDefinition {
|
||||
systemKey: string | null
|
||||
linkedAccountId: string | null
|
||||
archived: boolean
|
||||
mccCodes: number[]
|
||||
}
|
||||
|
||||
export interface CreateCategoryPayload {
|
||||
name: string
|
||||
kind: CreatableCategoryKind
|
||||
mccCodes?: number[]
|
||||
}
|
||||
|
||||
export interface PatchCategoryDefinitionPayload {
|
||||
name?: string
|
||||
archived?: boolean
|
||||
mccCodes?: number[]
|
||||
}
|
||||
|
||||
export type ImportStatus =
|
||||
@@ -291,6 +294,8 @@ export interface ImportRow {
|
||||
direction: 'in' | 'out'
|
||||
bankStatus: RowBankStatus
|
||||
description: string | null
|
||||
mcc: number | null
|
||||
mccName: string | null
|
||||
payeeHint: string | null
|
||||
warnings: string[]
|
||||
decision: RowDecision
|
||||
@@ -322,13 +327,12 @@ export interface SaveRowDecisionPayload {
|
||||
|
||||
export interface ResolveHoldPayload {
|
||||
action: HoldResolutionAction
|
||||
reason: string
|
||||
reason?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
export interface ConfirmImportPayload {
|
||||
confirmedWithoutFullReconciliation?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface CategorySpendItem {
|
||||
|
||||
@@ -19,26 +19,42 @@ import { categoryLabel } from '@/lib/categoryLabel'
|
||||
|
||||
const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope']
|
||||
|
||||
function parseMccCodes(value: string): number[] {
|
||||
return value.split(',').map((code) => Number(code.trim()))
|
||||
}
|
||||
|
||||
function CreateCategoryForm() {
|
||||
const { t } = useTranslation()
|
||||
const create = useCreateCategory()
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1, t('common.requiredField')),
|
||||
kind: z.enum(['monthly', 'savings_envelope']),
|
||||
mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')).optional(),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: '', kind: 'monthly' },
|
||||
defaultValues: { name: '', kind: 'monthly', mccCodes: '' },
|
||||
shouldUnregister: true,
|
||||
})
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) })
|
||||
create.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
kind: values.kind,
|
||||
...(values.kind === 'monthly' && values.mccCodes
|
||||
? { mccCodes: parseMccCodes(values.mccCodes) }
|
||||
: {}),
|
||||
},
|
||||
{ onSuccess: () => reset({ name: '', kind: values.kind, mccCodes: '' }) },
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -55,6 +71,14 @@ function CreateCategoryForm() {
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
{watch('kind') === 'monthly' ? (
|
||||
<TextField
|
||||
label={t('budget.manager.mccCodes')}
|
||||
hint={t('budget.manager.mccCodesHint')}
|
||||
error={errors.mccCodes?.message}
|
||||
{...register('mccCodes')}
|
||||
/>
|
||||
) : null}
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? t('common.saving') : t('common.create')}
|
||||
</Button>
|
||||
@@ -103,12 +127,59 @@ function RenameModal({
|
||||
)
|
||||
}
|
||||
|
||||
function MccCodesModal({
|
||||
definition,
|
||||
onClose,
|
||||
}: {
|
||||
definition: CategoryDefinition
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const update = useUpdateCategoryDefinition()
|
||||
const schema = z.object({
|
||||
mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { mccCodes: definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ') },
|
||||
})
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
update.mutate(
|
||||
{ id: definition.id, payload: { mccCodes: values.mccCodes ? parseMccCodes(values.mccCodes) : [] } },
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t('budget.manager.editMccCodesTitle')}>
|
||||
<form onSubmit={submit} noValidate className="form-grid">
|
||||
<TextField
|
||||
label={t('budget.manager.mccCodes')}
|
||||
hint={t('budget.manager.mccCodesHint')}
|
||||
error={errors.mccCodes?.message}
|
||||
{...register('mccCodes')}
|
||||
/>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
{update.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const definitions = useCategoryDefinitions()
|
||||
const update = useUpdateCategoryDefinition()
|
||||
const [tab, setTab] = useState<'active' | 'archived'>('active')
|
||||
const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null)
|
||||
const [mccTarget, setMccTarget] = useState<CategoryDefinition | null>(null)
|
||||
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t('budget.manager.title')}>
|
||||
@@ -161,25 +232,44 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
{t(`budget.category.kind.${definition.kind}`)}
|
||||
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
|
||||
</span>
|
||||
{definition.mccCodes.length > 0 ? (
|
||||
<details className="manager-row-meta">
|
||||
<summary>{t('budget.manager.mccCodesCount', { count: definition.mccCodes.length })}</summary>
|
||||
{definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ')}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
{!isSystem ? (
|
||||
{!isSystem || (definition.kind === 'monthly' && !definition.archived) ? (
|
||||
<div className="inline-actions manager-row-actions">
|
||||
{!definition.archived ? (
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setRenameTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setArchiveTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.archive')}
|
||||
</Button>
|
||||
{!isSystem ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setRenameTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.rename')}
|
||||
</Button>
|
||||
) : null}
|
||||
{definition.kind === 'monthly' ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setMccTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.editMccCodes')}
|
||||
</Button>
|
||||
) : null}
|
||||
{!isSystem ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setArchiveTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.archive')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
@@ -204,6 +294,9 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
{renameTarget ? (
|
||||
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
|
||||
) : null}
|
||||
{mccTarget ? (
|
||||
<MccCodesModal definition={mccTarget} onClose={() => setMccTarget(null)} />
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
open={archiveTarget !== null}
|
||||
danger
|
||||
|
||||
@@ -67,6 +67,8 @@ export function useUpdateCategoryDefinition() {
|
||||
toast.push('success', t('toast.categoryArchived'))
|
||||
} else if (variables.payload.archived === false) {
|
||||
toast.push('success', t('toast.categoryRestored'))
|
||||
} else if (variables.payload.mccCodes !== undefined) {
|
||||
toast.push('success', t('toast.categoryMccUpdated'))
|
||||
} else {
|
||||
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
|
||||
}
|
||||
|
||||
@@ -254,6 +254,12 @@
|
||||
"systemBadge": "System",
|
||||
"systemProtected": "renaming and archiving are unavailable",
|
||||
"rename": "Rename",
|
||||
"mccCodes": "MCC codes",
|
||||
"mccCodesHint": "Optional: comma-separated 3- or 4-digit codes",
|
||||
"mccCodesInvalid": "Enter 3- or 4-digit MCC codes separated by commas",
|
||||
"mccCodesCount": "MCC codes ({{count}})",
|
||||
"editMccCodes": "Edit MCC",
|
||||
"editMccCodesTitle": "Category MCC codes",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"renameTitle": "Rename category",
|
||||
@@ -368,7 +374,7 @@
|
||||
"holdCancelledTitle": "Operation cancelled",
|
||||
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
|
||||
"holdReason": "Resolution reason",
|
||||
"holdReasonHint": "Required; kept in the audit journal",
|
||||
"holdReasonHint": "Optional; you can leave it blank",
|
||||
"holdResolution.confirmed_as_posted": "Posted",
|
||||
"holdResolution.discarded_as_cancelled": "Cancelled",
|
||||
"warnings": "Warnings ({{count}})",
|
||||
@@ -389,6 +395,7 @@
|
||||
"rowsTitle": "Statement rows",
|
||||
"noRows": "No rows.",
|
||||
"rowWarnings": "Notes",
|
||||
"mcc": "MCC",
|
||||
"rowWarnings.amount": "amount",
|
||||
"rowWarnings.date": "date",
|
||||
"rowWarnings.direction": "direction",
|
||||
@@ -405,7 +412,6 @@
|
||||
"confirmTitle": "Apply import",
|
||||
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
|
||||
"confirmWithout": "I confirm applying without full balance reconciliation",
|
||||
"confirmReason": "Reason",
|
||||
"reparse": "Reparse",
|
||||
"revert": "Revert import",
|
||||
"revertTitle": "Revert import",
|
||||
@@ -463,6 +469,7 @@
|
||||
"savingsConversionReverted": "Transfer reverted to expense",
|
||||
"categoryCreated": "Category “{{name}}” created",
|
||||
"categoryRenamed": "Category renamed to “{{name}}”",
|
||||
"categoryMccUpdated": "Category MCC codes updated",
|
||||
"categoryArchived": "Category archived",
|
||||
"categoryRestored": "Category restored",
|
||||
"planUpdated": "Plan updated",
|
||||
@@ -491,12 +498,27 @@
|
||||
"categories": {
|
||||
"uncategorized": "Uncategorized",
|
||||
"groceries": "Groceries",
|
||||
"cafes": "Cafes and restaurants",
|
||||
"transport": "Transport",
|
||||
"fuel": "Fuel and gas stations",
|
||||
"housing": "Housing",
|
||||
"utilities": "Utilities",
|
||||
"health": "Health",
|
||||
"electronics": "Electronics",
|
||||
"clothing": "Clothing and footwear",
|
||||
"beauty": "Beauty and personal care",
|
||||
"education": "Education",
|
||||
"entertainment": "Entertainment",
|
||||
"sport": "Sports and fitness",
|
||||
"travel": "Travel",
|
||||
"home": "Home improvement",
|
||||
"telecom": "Phone and internet",
|
||||
"kids": "Children",
|
||||
"pets": "Pets",
|
||||
"gifts": "Gifts and hobbies",
|
||||
"auto": "Automotive",
|
||||
"finance": "Finance and insurance",
|
||||
"others": "Other",
|
||||
"eating_out": "Eating out",
|
||||
"clothes": "Clothes",
|
||||
"personal": "Personal",
|
||||
|
||||
@@ -254,6 +254,12 @@
|
||||
"systemBadge": "Системная",
|
||||
"systemProtected": "переименование и архивирование недоступны",
|
||||
"rename": "Переименовать",
|
||||
"mccCodes": "Коды МСС",
|
||||
"mccCodesHint": "Необязательно: 3–4-значные коды через запятую",
|
||||
"mccCodesInvalid": "Укажите 3–4-значные коды МСС через запятую",
|
||||
"mccCodesCount": "Коды МСС ({{count}})",
|
||||
"editMccCodes": "Изменить МСС",
|
||||
"editMccCodesTitle": "Коды МСС категории",
|
||||
"archive": "В архив",
|
||||
"restore": "Восстановить",
|
||||
"renameTitle": "Переименовать категорию",
|
||||
@@ -368,7 +374,7 @@
|
||||
"holdCancelledTitle": "Операция отменена",
|
||||
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
|
||||
"holdReason": "Причина решения",
|
||||
"holdReasonHint": "Обязательно; попадёт в журнал аудита",
|
||||
"holdReasonHint": "Необязательно, можно оставить пустым",
|
||||
"holdResolution.confirmed_as_posted": "Проведена",
|
||||
"holdResolution.discarded_as_cancelled": "Отменена",
|
||||
"warnings": "Предупреждения ({{count}})",
|
||||
@@ -389,6 +395,7 @@
|
||||
"rowsTitle": "Строки выписки",
|
||||
"noRows": "Строк нет.",
|
||||
"rowWarnings": "Замечания",
|
||||
"mcc": "МСС",
|
||||
"rowWarnings.amount": "сумма",
|
||||
"rowWarnings.date": "дата",
|
||||
"rowWarnings.direction": "направление",
|
||||
@@ -405,7 +412,6 @@
|
||||
"confirmTitle": "Применить импорт",
|
||||
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
|
||||
"confirmWithout": "Подтверждаю применение без полной сверки остатков",
|
||||
"confirmReason": "Причина",
|
||||
"reparse": "Переразобрать",
|
||||
"revert": "Отменить применение",
|
||||
"revertTitle": "Отменить применение импорта",
|
||||
@@ -465,6 +471,7 @@
|
||||
"savingsConversionReverted": "Перевод возвращён в расходы",
|
||||
"categoryCreated": "Категория «{{name}}» создана",
|
||||
"categoryRenamed": "Категория переименована в «{{name}}»",
|
||||
"categoryMccUpdated": "Коды МСС категории обновлены",
|
||||
"categoryArchived": "Категория отправлена в архив",
|
||||
"categoryRestored": "Категория восстановлена",
|
||||
"planUpdated": "План обновлён",
|
||||
@@ -493,12 +500,27 @@
|
||||
"categories": {
|
||||
"uncategorized": "Без категории",
|
||||
"groceries": "Продукты",
|
||||
"cafes": "Кафе и рестораны",
|
||||
"transport": "Транспорт",
|
||||
"fuel": "Топливо и АЗС",
|
||||
"housing": "Жильё",
|
||||
"utilities": "Коммунальные",
|
||||
"health": "Здоровье",
|
||||
"electronics": "Электроника",
|
||||
"clothing": "Одежда и обувь",
|
||||
"beauty": "Красота и уход",
|
||||
"education": "Образование",
|
||||
"entertainment": "Развлечения",
|
||||
"sport": "Спорт и фитнес",
|
||||
"travel": "Путешествия",
|
||||
"home": "Дом и ремонт",
|
||||
"telecom": "Связь и интернет",
|
||||
"kids": "Дети",
|
||||
"pets": "Питомцы",
|
||||
"gifts": "Подарки и хобби",
|
||||
"auto": "Авто",
|
||||
"finance": "Финансы и страхование",
|
||||
"others": "Остальное",
|
||||
"eating_out": "Кафе и рестораны",
|
||||
"clothes": "Одежда",
|
||||
"personal": "Личное",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { z } from 'zod'
|
||||
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { TextareaField, TextField } from '@/components/ui/fields'
|
||||
import { TextareaField } from '@/components/ui/fields'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { QueryGate } from '@/components/ui/QueryGate'
|
||||
@@ -56,25 +53,15 @@ function HoldResolveModal({
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = currentLocale(i18n.language)
|
||||
const resolve = useResolveHold(importId)
|
||||
const schema = z.object({
|
||||
reason: z.string().trim().min(1, t('common.requiredField')),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { reason: '' },
|
||||
})
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
resolve.mutate(
|
||||
{ rowId: row.id, payload: { action, reason: values.reason } },
|
||||
{ rowId: row.id, payload: { action, reason: reason.trim() || undefined } },
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -101,8 +88,8 @@ function HoldResolveModal({
|
||||
<TextareaField
|
||||
label={t('imports.holdReason')}
|
||||
hint={t('imports.holdReasonHint')}
|
||||
error={errors.reason?.message}
|
||||
{...register('reason')}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={resolve.isPending}>
|
||||
{resolve.isPending ? t('common.saving') : t('common.confirm')}
|
||||
@@ -124,7 +111,6 @@ function ConfirmModal({
|
||||
const { t } = useTranslation()
|
||||
const confirm = useConfirmImport(importId)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const disabled = needsConfirmation && !acknowledged
|
||||
|
||||
@@ -142,20 +128,14 @@ function ConfirmModal({
|
||||
/>
|
||||
{t('imports.confirmWithout')}
|
||||
</label>
|
||||
<TextField
|
||||
label={t('imports.confirmReason')}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={disabled || confirm.isPending}
|
||||
onClick={() =>
|
||||
confirm.mutate(
|
||||
needsConfirmation ? { confirmedWithoutFullReconciliation: true, reason: reason || undefined } : {},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
confirm.mutate(needsConfirmation ? { confirmedWithoutFullReconciliation: true } : {}, {
|
||||
onSuccess: onClose,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('imports.confirm')}
|
||||
@@ -334,6 +314,9 @@ export function ImportDetailPage() {
|
||||
<td data-label={t('operations.description')} className="statement-description">
|
||||
{row.description ?? '—'}
|
||||
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null}
|
||||
{row.mcc !== null ? (
|
||||
<span className="muted"> · {t('imports.mcc')} {row.mcc}{row.mccName ? ` · ${row.mccName}` : ''}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td
|
||||
data-label={t('common.amount')}
|
||||
|
||||
@@ -70,10 +70,21 @@ const diningDefinition: CategoryDefinition = {
|
||||
systemKey: null,
|
||||
linkedAccountId: null,
|
||||
archived: false,
|
||||
mccCodes: [5999, 5994],
|
||||
}
|
||||
|
||||
const definitions: CategoryDefinition[] = [
|
||||
diningDefinition,
|
||||
{
|
||||
id: 'def-groceries',
|
||||
name: 'Продукты и супермаркеты',
|
||||
nameKey: 'groceries',
|
||||
kind: 'monthly',
|
||||
systemKey: 'groceries',
|
||||
linkedAccountId: null,
|
||||
archived: false,
|
||||
mccCodes: [5411, 5422],
|
||||
},
|
||||
{
|
||||
id: 'def-savings',
|
||||
name: 'Накопления',
|
||||
@@ -82,6 +93,7 @@ const definitions: CategoryDefinition[] = [
|
||||
systemKey: 'savings',
|
||||
linkedAccountId: 'a-sys',
|
||||
archived: false,
|
||||
mccCodes: [],
|
||||
},
|
||||
{
|
||||
id: 'def-old-gym',
|
||||
@@ -91,6 +103,7 @@ const definitions: CategoryDefinition[] = [
|
||||
systemKey: null,
|
||||
linkedAccountId: null,
|
||||
archived: true,
|
||||
mccCodes: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -129,6 +142,75 @@ describe('BudgetPage category manager lifecycle', () => {
|
||||
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a category with multiple MCC codes', async () => {
|
||||
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
|
||||
const { dialog } = await openManager()
|
||||
await userEvent.type(within(dialog).getByLabelText('Name'), 'Shops')
|
||||
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999, 5994')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(createCategoryMock).toHaveBeenCalledWith({
|
||||
name: 'Shops',
|
||||
kind: 'monthly',
|
||||
mccCodes: [5999, 5994],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer MCC mapping for a savings envelope', async () => {
|
||||
createCategoryMock.mockResolvedValue(definition({ kind: 'savings_envelope' }))
|
||||
const { dialog } = await openManager()
|
||||
await userEvent.type(within(dialog).getByLabelText('Name'), 'Trip')
|
||||
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999')
|
||||
await userEvent.selectOptions(within(dialog).getByRole('combobox', { name: 'Type' }), 'savings_envelope')
|
||||
expect(within(dialog).queryByLabelText('MCC codes')).not.toBeInTheDocument()
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(createCategoryMock).toHaveBeenCalledWith({ name: 'Trip', kind: 'savings_envelope' })
|
||||
})
|
||||
|
||||
it('lets users release an MCC code from a pre-created category', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definitions[1]!)
|
||||
const { screen, dialog } = await openManager()
|
||||
const groceriesRow = (await within(dialog).findByText('Groceries')).closest('li') as HTMLElement
|
||||
expect(within(groceriesRow).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument()
|
||||
await userEvent.click(within(groceriesRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '5422')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-groceries', { mccCodes: [5422] })
|
||||
})
|
||||
|
||||
it('shows and updates multiple MCC codes on a category', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [5999, 5994, 5992] }))
|
||||
const { screen, dialog } = await openManager()
|
||||
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
|
||||
await userEvent.click(within(diningRow).getByText('MCC codes (2)'))
|
||||
expect(within(diningRow).getByText('5999, 5994')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '5999, 5994, 5992')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', {
|
||||
mccCodes: [5999, 5994, 5992],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a three-digit MCC code', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [742] }))
|
||||
const { screen, dialog } = await openManager()
|
||||
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
|
||||
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '742')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { mccCodes: [742] })
|
||||
})
|
||||
|
||||
it('renames and archives a user category through inline actions', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
|
||||
const { screen, dialog } = await openManager()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { within } from '@testing-library/react'
|
||||
import { api } from '@/api/endpoints'
|
||||
import type { ImportDetail, ImportRow } from '@/api/types'
|
||||
import { ImportDetailPage } from '@/pages/ImportDetailPage'
|
||||
import { renderWithProviders } from './helpers'
|
||||
|
||||
vi.mock('@/api/endpoints', () => ({
|
||||
api: {
|
||||
imports: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
saveRowDecision: vi.fn(),
|
||||
resolveHold: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
reparse: vi.fn(),
|
||||
revert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const getImportMock = vi.mocked(api.imports.get)
|
||||
const confirmMock = vi.mocked(api.imports.confirm)
|
||||
|
||||
function postedRow(): ImportRow {
|
||||
return {
|
||||
id: 'row-1',
|
||||
externalId: 'ext-1',
|
||||
occurredOn: '2026-09-10',
|
||||
amountKopecks: 150000,
|
||||
direction: 'out',
|
||||
bankStatus: 'posted',
|
||||
description: 'Coffee beans',
|
||||
mcc: null,
|
||||
mccName: null,
|
||||
payeeHint: null,
|
||||
warnings: [],
|
||||
decision: 'create',
|
||||
duplicateOfOperationId: null,
|
||||
holdResolution: null,
|
||||
}
|
||||
}
|
||||
|
||||
function detailFixture(reconciliationStatus: 'matched' | 'mismatched'): ImportDetail {
|
||||
const rows = [postedRow()]
|
||||
return {
|
||||
id: 'imp-1',
|
||||
fileName: 'alfa.xlsx',
|
||||
bankAdapter: 'alfa_debit_xlsx',
|
||||
status: 'ready',
|
||||
accountId: 'a1',
|
||||
accountName: 'Alfa Card',
|
||||
periodFrom: null,
|
||||
periodTo: null,
|
||||
unresolvedHoldCount: 0,
|
||||
createdAt: '2026-09-01T10:00:00Z',
|
||||
failureReason: null,
|
||||
blockers: [],
|
||||
warnings: [],
|
||||
reconciliation: {
|
||||
status: reconciliationStatus,
|
||||
openingBalanceKopecks: null,
|
||||
closingBalanceKopecks: null,
|
||||
computedClosingKopecks: null,
|
||||
reason: null,
|
||||
},
|
||||
rows,
|
||||
totals: {
|
||||
total: rows.length,
|
||||
create: rows.length,
|
||||
discard: 0,
|
||||
duplicate: 0,
|
||||
transfer: 0,
|
||||
savingsTransfer: 0,
|
||||
pending: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ImportDetailPage apply import', () => {
|
||||
beforeEach(() => {
|
||||
getImportMock.mockResolvedValue(detailFixture('matched'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('applies the import without a reason field', async () => {
|
||||
confirmMock.mockResolvedValue(detailFixture('matched'))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Apply import' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
|
||||
|
||||
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Apply import' }))
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1)
|
||||
expect(confirmMock).toHaveBeenCalledWith('imp-1', {})
|
||||
})
|
||||
|
||||
it('applies without reconciliation and never sends a reason', async () => {
|
||||
getImportMock.mockResolvedValue(detailFixture('mismatched'))
|
||||
confirmMock.mockResolvedValue(detailFixture('mismatched'))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Apply import' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
|
||||
|
||||
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
|
||||
|
||||
const submit = within(dialog).getByRole('button', { name: 'Apply import' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('checkbox', {
|
||||
name: 'I confirm applying without full balance reconciliation',
|
||||
}),
|
||||
)
|
||||
expect(submit).toBeEnabled()
|
||||
await userEvent.click(submit)
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1)
|
||||
expect(confirmMock).toHaveBeenCalledWith('imp-1', {
|
||||
confirmedWithoutFullReconciliation: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,8 @@ function row(overrides: Partial<ImportRow>): ImportRow {
|
||||
direction: 'out',
|
||||
bankStatus: 'posted',
|
||||
description: 'Coffee beans',
|
||||
mcc: null,
|
||||
mccName: null,
|
||||
payeeHint: null,
|
||||
warnings: [],
|
||||
decision: 'create',
|
||||
@@ -124,7 +126,15 @@ describe('ImportDetailPage HOLD resolution', () => {
|
||||
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires a reason and resolves a hold as posted', async () => {
|
||||
it('labels imported MCC codes even without a dedicated category', async () => {
|
||||
getImportMock.mockResolvedValue(
|
||||
detailFixture([row({ mcc: 7995, mccName: 'Лотереи и азартные игры' })]),
|
||||
)
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
expect(await screen.findByText(/MCC 7995 · Лотереи и азартные игры/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves a hold as posted without requiring a reason', async () => {
|
||||
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Operation posted' })
|
||||
@@ -132,17 +142,30 @@ describe('ImportDetailPage HOLD resolution', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Operation posted' })
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
expect(await screen.findByText('Required field')).toBeInTheDocument()
|
||||
expect(resolveHoldMock).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Charge cleared at the bank')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
|
||||
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
|
||||
action: 'confirm_as_posted',
|
||||
reason: 'Charge cleared at the bank',
|
||||
reason: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the optional reason when provided', async () => {
|
||||
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Operation cancelled' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Operation cancelled' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Operation cancelled' })
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Duplicate charge')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
|
||||
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
|
||||
action: 'discard_as_cancelled',
|
||||
reason: 'Duplicate charge',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user