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:
k1nq
2026-09-23 10:57:27 +05:00
parent db4dd7572e
commit 63d065011b
40 changed files with 11352 additions and 7475 deletions
+6 -2
View File
@@ -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
+2
View File
@@ -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 ?? '' }))
}
+24 -2
View File
@@ -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",
+24 -2
View File
@@ -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": "Личное",
+15 -32
View File
@@ -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()
+134
View File
@@ -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',
})
})