diff --git a/package.json b/package.json index 462018f..3985539 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "openapi:check": "node scripts/check-openapi.mjs", "knip": "npm run build:packages && knip", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", + "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "prepare": "husky", "preview": "vite preview", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts index a99a3c5..f34effb 100644 --- a/src/entities/bank-card/model/form-validation.test.ts +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -51,6 +51,9 @@ describe("bank form input", () => { expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); + expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false); + expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true); + expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true); }); }); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 56f11db..0b87a25 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,3 +1,5 @@ +import { moscowDate } from "@/shared/lib"; + const DECIMAL_PART_COUNT = 2; const CENTS_PRECISION = 2; @@ -21,12 +23,7 @@ function bankMonth(value: null | string) { } function bankToday() { - return new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).format(new Date()); + return moscowDate(new Date()); } function moneyCents(value: string): bigint | null { diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts index 3d4346b..9051b90 100644 --- a/src/entities/bank-card/model/form.schema.ts +++ b/src/entities/bank-card/model/form.schema.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { calendarDate } from "@/shared/lib"; import { bankToday, moneyCents } from "./form-validation"; -const ISO_DATE_LENGTH = 10; const MAX_CARD_NAME_LENGTH = 100; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; @@ -34,11 +34,8 @@ const bankDateSchema = z .string() .refine( value => - /^\d{4}-\d{2}-\d{2}$/.test(value) + calendarDate.safeParse(value).success && value >= "0001-01-01" - && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) - && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) - === value && value <= bankToday(), { error: validationMessage("bank.validation.date") }, ); diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts new file mode 100644 index 0000000..01ce9e7 --- /dev/null +++ b/src/pages/account-income/model/display.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import type { IncomeAnalytics } from "@/entities/income"; + +import { resource } from "@/test/resource"; + +import { toIncomeDisplayModel } from "./display"; + +const analytics: IncomeAnalytics = { + accountId: "account-1", + bucketGranularity: "day", + buckets: [], + calculatedAt: "2026-08-27T00:00:00Z", + entries: [], + instruments: [], + period: { from: "2026-08-01", to: "2026-08-27" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], + }, + totalsByCurrency: [], + yearComparison: null, +}; + +function comparison(currency: string) { + return { + compared: summary(currency, "1", "0"), + currency, + difference: summary(currency, "9", "2"), + selected: summary(currency, "10", "2"), + }; +} + +function money(currency: string, value: string) { + return { currency, value }; +} + +function summary(currency: string, coupon: string, dividend: string) { + return { + coupon: money(currency, coupon), + currency, + dividend: money(currency, dividend), + total: money(currency, String(Number(coupon) + Number(dividend))), + }; +} + +describe("income comparison display", () => { + it.each([ + ["25", "100", "+25 %"], + ["-25", "100", "-25 %"], + ["0", "100", "0 %"], + ["-0.000", "100", "0 %"], + ["0.0001", "100", "0 %"], + ["1", "3", "+33,3 %"], + ["0.01", "0.04", "+25 %"], + ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], + [ + "10", + "0", + null, + ], + [ + "0", + "0", + null, + ], + [ + "10", + "-0.00", + null, + ], + [ + "10", + "-100", + null, + ], + [ + "9".repeat(400), + "1", + null, + ], + [ + "1", + "9".repeat(400), + null, + ], + ])("formats change %s against base %s", (difference, base, expected) => { + const value = comparison("RUB"); + value.difference.coupon = money("RUB", difference); + value.compared.coupon = money("RUB", base); + + const display = toIncomeDisplayModel({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [value], + }, + }); + + const result = display?.comparison?.values[0]; + expect( + result?.percentages.coupon?.replaceAll("\u{A0}", " ") + + ?? null, + ).toBe(expected); + expect(result?.difference.coupon.value).toBe(difference); + expect(result?.compared.coupon.value).toBe(base); + }); + + it("formats calendar ranges across years and preserves separate currencies", () => { + const display = toIncomeDisplayModel({ + ...analytics, + period: { from: "2025-12-01", to: "2026-01-09" }, + yearComparison: { + period: { from: "2024-12-01", to: "2025-01-09" }, + totalsByCurrency: [comparison("RUB"), comparison("USD")], + }, + }); + + expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2025 – 09.01.2026", + ); + expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2024 – 09.01.2025", + ); + expect(display?.comparison?.values.map(value => value.currency)).toEqual([ + "RUB", + "USD", + ]); + }); +}); + +it("maps comparison notices by role regardless of dependency order or resource key", () => { + const selected = { ...resource(analytics).meta, role: "selected" as const }; + + const previous + = { + ...resource(null).meta, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + }; + + const year = { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }; + + for (const dependencies of [ + [selected, previous, year], + [year, selected, previous], + ]) { + const display = toIncomeDisplayModel(analytics, { + ...resource(analytics).meta, + dependencies, + }); + + expect(display?.previousNotice).toBe("comparisonFailed"); + expect(display?.yearNotice).toBe("yearUnavailable"); + expect(display?.previousComparison).not.toBeNull(); + } +}); diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts new file mode 100644 index 0000000..e1eab94 --- /dev/null +++ b/src/pages/account-income/model/display.ts @@ -0,0 +1,173 @@ +import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; + +import { formatDate } from "@/shared/lib"; + +import type { IncomePeriod } from "./period"; + +type ComparisonNotice + = | "comparisonFailed" + | "comparisonPending" + | "previousUnavailable" + | "yearUnavailable"; + +type IncomeDisplayModel = { + comparison: null + | { + comparedPeriod: string; + selectedPeriod: string; + values: (NonNullable< + IncomeAnalytics["yearComparison"] + >["totalsByCurrency"][number] & { + percentages: Record<"coupon" + | "dividend" + | "total", null | string>; + })[]; + }; + entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; + previousComparison: IncomeDisplayModel["comparison"]; + previousNotice: ComparisonNotice; + yearNotice: ComparisonNotice; +}; + +type IncomeMeta = ReturnType["meta"]; + +function comparisonDisplay( + period: IncomePeriod, + comparison: IncomeAnalytics["yearComparison"], +): IncomeDisplayModel["comparison"] { + if (!comparison) + return null; + + return { + comparedPeriod: formatComparisonPeriod(comparison.period), + selectedPeriod: formatComparisonPeriod(period), + values: comparison.totalsByCurrency.map(value => ({ + ...value, + percentages: { + coupon: formatIncomeChange( + value.difference.coupon.value, + value.compared.coupon.value, + ), + dividend: formatIncomeChange( + value.difference.dividend.value, + value.compared.dividend.value, + ), + total: formatIncomeChange( + value.difference.total.value, + value.compared.total.value, + ), + }, + })), + }; +} + +function comparisonNotice( + dependency: + NonNullable["dependencies"]>[number] | undefined, + unavailable: "previousUnavailable" | "yearUnavailable", +): ComparisonNotice { + if (!dependency || dependency.unavailableReason) return unavailable; + + return dependency.sync.state === "failed" + ? "comparisonFailed" + : "comparisonPending"; +} + +function formatComparisonPeriod(period: IncomePeriod): string { + return `${formatDate(period.from)} – ${formatDate(period.to)}`; +} + +function formatIncomeChange( + difference: string, + compared: string, +): null | string { + // Display-only ratio; server-provided Money values remain exact decimal strings. + const base = Number(compared); + if (!Number.isFinite(base) || base <= 0) + return null; + const ratio = Number(difference) / base; + if (!Number.isFinite(ratio)) + return null; + + return new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + signDisplay: "exceptZero", + style: "percent", + }).format(ratio); +} + +function toIncomeDisplayModel( + analytics: IncomeAnalytics, + meta?: IncomeMeta, +): IncomeDisplayModel | null { + const instruments = new Map< + string, + { + currency: string; + includedCount: number; + receiptCount: number; + } + >(); + + const currencies = new Set( + analytics.totalsByCurrency.map(({ currency }) => currency), + ); + + for (const instrument of analytics.instruments) { + if ( + typeof instrument.key !== "string" + || !instrument.key.trim() + || instruments.has(instrument.key) + || !currencies.has(instrument.total.currency) + ) + + return null; + instruments.set(instrument.key, { + currency: instrument.total.currency, + includedCount: 0, + receiptCount: instrument.receiptCount, + }); + } + + const entries: IncomeDisplayModel["entries"] = []; + const occurrences = new Map(); + + for (const entry of analytics.entries) { + if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) + return null; + const instrument = instruments.get(entry.instrumentKey); + if (!instrument || entry.payment?.currency !== instrument.currency) + return null; + const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); + const occurrence = occurrences.get(occurrenceKey) ?? 0; + occurrences.set(occurrenceKey, occurrence + 1); + instrument.includedCount += 1; + entries.push({ + key: JSON.stringify([ + entry.instrumentKey, + entry.id, + occurrence, + entry.logoName, + ]), + operation: entry, + }); + } + + for (const instrument of instruments.values()) { + if (instrument.includedCount !== instrument.receiptCount) + return null; + } + + const previous = meta?.dependencies?.find(({ role }) => role === "previous"); + const year = meta?.dependencies?.find(({ role }) => role === "year"); + + return { + comparison: comparisonDisplay(analytics.period, analytics.yearComparison), + entries, + previousComparison: comparisonDisplay(analytics.period, analytics.previous), + previousNotice: comparisonNotice(previous, "previousUnavailable"), + yearNotice: comparisonNotice(year, "yearUnavailable"), + }; +} + +export { type IncomeDisplayModel, toIncomeDisplayModel }; diff --git a/src/pages/account-income/model/income.schema.ts b/src/pages/account-income/model/income.schema.ts index 9d22512..cc2c459 100644 --- a/src/pages/account-income/model/income.schema.ts +++ b/src/pages/account-income/model/income.schema.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { moscowDate } from "@/shared/lib"; -import { moscowDate, parseDraftDate } from "./period"; +import { parseDraftDate } from "./period"; const draftDate = z .string() diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts index 4662a7e..fb3a5c5 100644 --- a/src/pages/account-income/model/period.ts +++ b/src/pages/account-income/model/period.ts @@ -1,47 +1,22 @@ -const ISO_DATE_LENGTH = 10; +import { calendarDate } from "@/shared/lib"; + const DATE_PART_COUNT = 3; type IncomePeriod = { from: string; to: string }; -const datePattern = /^\d{4}-\d{2}-\d{2}$/; - function formatDraftDate(value: string): string { const [year, month, day] = value.split("-", DATE_PART_COUNT); return `${day}.${month}.${year}`; } -function isDate(value: string): boolean { - if (!datePattern.test(value)) return false; - const parsed = new Date(`${value}T00:00:00Z`); - - return ( - Number.isFinite(parsed.valueOf()) - && parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value - ); -} - -function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).formatToParts(value); - - const part = (type: string) => - parts.find(item => item.type === type)?.value; - - return `${part("year")}-${part("month")}-${part("day")}`; -} - function parseDraftDate(value: string): null | string { if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; const [day, month, year] = value.split(".", DATE_PART_COUNT); const date = `${year}-${month}-${day}`; - return isDate(date) + return calendarDate.safeParse(date).success ? date : null; } @@ -50,8 +25,5 @@ export { formatDraftDate, type IncomePeriod as IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 614b29d..0ea45e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -30,10 +30,9 @@ import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { isDate } from "../../model/period"; +import { toIncomeDisplayModel } from "../../model/display"; import { AccountIncomePage } from "./AccountIncomePage"; import { - toIncomeDisplayModel, useAccountIncomePageViewModel, } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; @@ -469,12 +468,6 @@ describe("AccountIncomePage", () => { expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); }); - it("rejects malformed calendar dates without throwing", () => { - expect(isDate("2026-99-99")).toBe(false); - expect(isDate("2026-02-29")).toBe(false); - expect(isDate("2024-02-29")).toBe(true); - }); - it("renders realized text and comparisons without subscribing to events", async () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( @@ -584,92 +577,6 @@ describe("AccountIncomePage", () => { }); }); -describe("income comparison display", () => { - it.each([ - ["25", "100", "+25 %"], - ["-25", "100", "-25 %"], - ["0", "100", "0 %"], - ["-0.000", "100", "0 %"], - ["0.0001", "100", "0 %"], - ["1", "3", "+33,3 %"], - ["0.01", "0.04", "+25 %"], - ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - [ - "10", - "0", - null, - ], - [ - "0", - "0", - null, - ], - [ - "10", - "-0.00", - null, - ], - [ - "10", - "-100", - null, - ], - [ - "9".repeat(400), - "1", - null, - ], - [ - "1", - "9".repeat(400), - null, - ], - ])("formats change %s against base %s", (difference, base, expected) => { - const value = comparison("RUB"); - value.difference.coupon = money("RUB", difference); - value.compared.coupon = money("RUB", base); - - const display = toIncomeDisplayModel({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [value], - }, - }); - - const result = display?.comparison?.values[0]; - expect( - result?.percentages.coupon?.replaceAll("\u{A0}", " ") - - ?? null, - ).toBe(expected); - expect(result?.difference.coupon.value).toBe(difference); - expect(result?.compared.coupon.value).toBe(base); - }); - - it("formats calendar ranges across years and preserves separate currencies", () => { - const display = toIncomeDisplayModel({ - ...analytics, - period: { from: "2025-12-01", to: "2026-01-09" }, - yearComparison: { - period: { from: "2024-12-01", to: "2025-01-09" }, - totalsByCurrency: [comparison("RUB"), comparison("USD")], - }, - }); - - expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2025 – 09.01.2026", - ); - expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2024 – 09.01.2025", - ); - expect(display?.comparison?.values.map(value => value.currency)).toEqual([ - "RUB", - "USD", - ]); - }); -}); - const LocationProbe: FC = () => { const location = useLocation(); const navigate = useNavigate(); @@ -978,43 +885,6 @@ describe("income period and snapshot boundary", () => { }); }); -it("maps comparison notices by role regardless of dependency order or resource key", () => { - const selected = { ...resource(analytics).meta, role: "selected" as const }; - - const previous - = { - ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, - state: "failed" as const, - }, - }; - - const year = { - ...resource(null).meta, - resourceKey: "opaque/previous-looking-key", - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }; - - for (const dependencies of [ - [selected, previous, year], - [year, selected, previous], - ]) { - const display = toIncomeDisplayModel(analytics, { - ...resource(analytics).meta, - dependencies, - }); - - expect(display?.previousNotice).toBe("comparisonFailed"); - expect(display?.yearNotice).toBe("yearUnavailable"); - expect(display?.previousComparison).not.toBeNull(); - } -}); - it.each([false, true])( "shows unavailable comparisons for absent roles without positional fallback (legacy=%s)", async (legacy) => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index ab419df..937f9e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -5,16 +5,17 @@ import { useParams, useSearchParams } from "react-router"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; -import { formatDate } from "@/shared/lib"; +import { isValidPeriod, moscowDate } from "@/shared/lib"; +import { + type IncomeDisplayModel, + toIncomeDisplayModel, +} from "../../model/display"; import { incomeFormSchema } from "../../model/income.schema"; import { formatDraftDate, type IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, } from "../../model/period"; @@ -43,33 +44,6 @@ type AccountIncomePageViewModel } | { state: "loading" }; -type ComparisonNotice - = | "comparisonFailed" - | "comparisonPending" - | "previousUnavailable" - | "yearUnavailable"; - -type IncomeDisplayModel = { - comparison: null - | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" - | "dividend" - | "total", null | string>; - })[]; - }; - entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; -}; - -type IncomeMeta = ReturnType["meta"]; - const useAccountIncomePageViewModel = ( scopeId?: string, ): AccountIncomePageViewModel => { @@ -86,7 +60,7 @@ const useAccountIncomePageViewModel = ( ); const period = useMemo( - () => (isValidPeriod(requested) ? requested : defaultPeriod()), + () => (isValidIncomePeriod(requested) ? requested : defaultPeriod()), [requested], ); @@ -104,7 +78,7 @@ const useAccountIncomePageViewModel = ( ); useEffect(() => { - if (!isValidPeriod(requested)) + if (!isValidIncomePeriod(requested)) setSearchParameters(period, { replace: true }); }, [period, requested, setSearchParameters]); useEffect(() => { @@ -156,48 +130,6 @@ const useAccountIncomePageViewModel = ( }; }; -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) - return null; - - return { - comparedPeriod: formatComparisonPeriod(comparison.period), - selectedPeriod: formatComparisonPeriod(period), - values: comparison.totalsByCurrency.map(value => ({ - ...value, - percentages: { - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - }, - })), - }; -} - -function comparisonNotice( - dependency: - NonNullable["dependencies"]>[number] | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - - return dependency.sync.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); @@ -208,120 +140,12 @@ function draftPeriod(period: IncomePeriod): IncomeForm { return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; } -function formatComparisonPeriod(period: IncomePeriod): string { - return `${formatDate(period.from)} – ${formatDate(period.to)}`; -} - -function formatIncomeChange( - difference: string, - compared: string, -): null | string { - // Display-only ratio; server-provided Money values remain exact decimal strings. - const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) - return null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) - return null; - - return new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 1, - signDisplay: "exceptZero", - style: "percent", - }).format(ratio); -} - -function isValidPeriod(period: IncomePeriod): period is IncomePeriod { - if (!isDate(period.from) || !isDate(period.to)) return false; - const from = new Date(`${period.from}T00:00:00Z`); - const to = new Date(`${period.to}T00:00:00Z`); - const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); - - return ( - from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from - && to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to - && from <= to - && to <= today - ); -} - -function toIncomeDisplayModel( - analytics: IncomeAnalytics, - meta?: IncomeMeta, -): IncomeDisplayModel | null { - const instruments = new Map< - string, - { - currency: string; - includedCount: number; - receiptCount: number; - } - >(); - - const currencies = new Set( - analytics.totalsByCurrency.map(({ currency }) => currency), - ); - - for (const instrument of analytics.instruments) { - if ( - typeof instrument.key !== "string" - || !instrument.key.trim() - || instruments.has(instrument.key) - || !currencies.has(instrument.total.currency) - ) - - return null; - instruments.set(instrument.key, { - currency: instrument.total.currency, - includedCount: 0, - receiptCount: instrument.receiptCount, - }); - } - - const entries: IncomeDisplayModel["entries"] = []; - const occurrences = new Map(); - - for (const entry of analytics.entries) { - if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) - return null; - const instrument = instruments.get(entry.instrumentKey); - if (!instrument || entry.payment?.currency !== instrument.currency) - return null; - const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); - const occurrence = occurrences.get(occurrenceKey) ?? 0; - occurrences.set(occurrenceKey, occurrence + 1); - instrument.includedCount += 1; - entries.push({ - key: JSON.stringify([ - entry.instrumentKey, - entry.id, - occurrence, - entry.logoName, - ]), - operation: entry, - }); - } - - for (const instrument of instruments.values()) { - if (instrument.includedCount !== instrument.receiptCount) - return null; - } - - const previous = meta?.dependencies?.find(({ role }) => role === "previous"); - const year = meta?.dependencies?.find(({ role }) => role === "year"); - - return { - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - entries, - previousComparison: comparisonDisplay(analytics.period, analytics.previous), - previousNotice: comparisonNotice(previous, "previousUnavailable"), - yearNotice: comparisonNotice(year, "yearUnavailable"), - }; +function isValidIncomePeriod(period: IncomePeriod): boolean { + return isValidPeriod(period.from, period.to) + && period.to <= moscowDate(new Date()); } export { type AccountIncomePageViewModel, - type IncomeDisplayModel, - toIncomeDisplayModel, useAccountIncomePageViewModel, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index 2cd9f14..5b0ceeb 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; +import type { IncomeDisplayModel } from "../../../model/display"; import classes from "../AccountIncomePage.module.css"; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts index 3bf5ae2..199c5c1 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts @@ -2,10 +2,11 @@ import { useState } from "react"; import { useController, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { moscowDate } from "@/shared/lib"; + import { formatDraftDate, type IncomeForm, - moscowDate, parseDraftDate, } from "../../../../model/period"; diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx index 78d57d9..54f2c93 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx @@ -91,7 +91,6 @@ const OperationExample: FC = (props) => { }, ], limit: { currency: "RUB", value: "500" }, - onSelect: () => {}, }} saveDisabled={false} sourcePage={1} diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index 1729b71..8356b6d 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -245,14 +245,19 @@ describe("OperationForm", () => { version: 1, }; + const recent = { + ...purchase, + availableRefund: money("100"), + description: "Недавняя покупка", + id: "recent", + }; + server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ hasNext: new URL(request.url).searchParams.get("page") !== "2", items: - new URL(request.url).searchParams.get("page") === "2" - ? [purchase] - : [], + [new URL(request.url).searchParams.get("page") === "2" ? purchase : recent], page: 1, total: 1, }); @@ -260,9 +265,19 @@ describe("OperationForm", () => { http.get("*/api/v1/bank-cards/operations/purchase", () => HttpResponse.json(purchase), ), + http.get("*/api/v1/bank-cards/operations/recent", () => + HttpResponse.json(recent), + ), ); const close = setup(); await select(/Вид операции/, "Возврат"); + await select(/Исходная покупка/, /Недавняя покупка/); + await screen.findByText("Доступно к возврату: 100 ₽"); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "150" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Сумма превышает доступный возврат."); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), ); diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 159b423..02db9f9 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -142,12 +142,11 @@ type RefundFieldsProps = { editing: boolean; expenses: Choice[]; limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; }; const RefundFields: FC = (props) => { const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t } - = useRefundFieldsVm(props); + = useRefundFieldsVm(); return ( diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index a8eb1da..c45f178 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -35,11 +35,6 @@ const useOperationFormVm = (props: OperationFormProps) => { const [latest, setLatest] = useState(); const [sourcePage, setSourcePage] = useState(1); - const [sourceId, setSourceId] = useState( - props.operation?.originalExpenseId ?? "", - ); - - const sourceQuery = useBankOperation(sourceId || undefined); const currentQuery = useBankOperation(props.operation?.id); const [refundLimit, setRefundLimit] = useState( @@ -64,7 +59,7 @@ const useOperationFormVm = (props: OperationFormProps) => { originalExpenseId: props.operation?.originalExpenseId ?? "", targetCardId: props.operation?.targetCardId ?? "", }, - resolver: zodResolver( + resolver: (values, context, options) => zodResolver( createOperationSchema({ cards: props.cards, categories: categories.data, @@ -74,9 +69,16 @@ const useOperationFormVm = (props: OperationFormProps) => { : sourceQuery.data?.availableRefund, submittedValues, }), - ), + )(values, context, options), }); + const sourceId = useWatch({ + control: methods.control, + name: "originalExpenseId", + }); + + const sourceQuery = useBankOperation(sourceId || undefined); + const kind = useWatch({ control: methods.control, name: "kind" }); const expenses = useBankExpenses( @@ -242,7 +244,6 @@ const useOperationFormVm = (props: OperationFormProps) => { limit: props.operation ? refundLimit : sourceQuery.data?.availableRefund, - onSelect: setSourceId, }, saveDisabled: (kind === "expense" && !categories.data) diff --git a/src/pages/card/ui/OperationForm/RefundFields.vm.ts b/src/pages/card/ui/OperationForm/RefundFields.vm.ts index c53fab9..afaf3ec 100644 --- a/src/pages/card/ui/OperationForm/RefundFields.vm.ts +++ b/src/pages/card/ui/OperationForm/RefundFields.vm.ts @@ -1,20 +1,9 @@ import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { BankOperation } from "@/entities/bank-card"; - import type { OperationValues } from "./OperationForm.types"; -type Choice = { label: string; value: string }; - -type RefundFieldsProps = { - editing: boolean; - expenses: Choice[]; - limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; -}; - -function useRefundFieldsVm(props: RefundFieldsProps) { +function useRefundFieldsVm() { const { t } = useTranslation(); const { @@ -29,7 +18,6 @@ function useRefundFieldsVm(props: RefundFieldsProps) { const onExpenseChange: (value: null | string) => void = (value) => { originalExpenseIdField.onChange(value ?? ""); - props.onSelect(value ?? ""); }; return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }; diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts new file mode 100644 index 0000000..ae8a521 --- /dev/null +++ b/src/shared/lib/calendar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { isValidPeriod, moscowDate } from "./calendar"; +import { calendarDate } from "./calendar.schema"; + +describe("calendar dates", () => { + it.each([ + ["0000-02-29", true], + ["0001-01-01", true], + ["0099-12-31", true], + ["1900-02-29", false], + ["2000-02-29", true], + ["2024-02-29", true], + ["2026-02-29", false], + ["2026-99-99", false], + ["2026-1-01", false], + ["", false], + ])("validates %s without Date normalization", (value, valid) => { + expect(calendarDate.safeParse(value).success).toBe(valid); + }); + + it("accepts open or single-day periods and rejects reversed ranges", () => { + expect(isValidPeriod(null, null)).toBe(true); + expect(isValidPeriod(null, "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-01", null)).toBe(true); + expect(isValidPeriod("2026-09-01", "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-02", "2026-09-01")).toBe(false); + expect(isValidPeriod("", "2026-09-01")).toBe(false); + }); + + it("changes the calendar day at Moscow midnight across a year boundary", () => { + expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31"); + expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01"); + }); +}); diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index 997b5c8..81ba27f 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,4 +9,18 @@ function isValidPeriod(from: null | string, to: null | string) { ); } -export { isValidPeriod }; +function moscowDate(value: Date): string { + const parts = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: "Europe/Moscow", + year: "numeric", + }).formatToParts(value); + + const part = (type: string) => + parts.find(item => item.type === type)?.value; + + return `${part("year")}-${part("month")}-${part("day")}`; +} + +export { isValidPeriod, moscowDate }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index d490133..8fd812a 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,4 @@ -export { isValidPeriod } from "./calendar"; +export { isValidPeriod, moscowDate } from "./calendar"; export { calendarDate } from "./calendar.schema"; export { formatChartValue,