refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -0,0 +1,30 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/queries";
const MAX_CATEGORY_NAME_LENGTH = 80;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({
name: z
.string()
.trim()
.refine(
value => value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
{ error: validationMessage("bank.validation.category") },
)
.refine(
value =>
!categories?.some(
c =>
c.id !== editingId
&& c.name.toLocaleLowerCase("ru-RU")
=== value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
),
});
}
export { categorySchema };
@@ -1,14 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
bankCardSchema,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankMonth,
bankToday,
positiveMoneySchema,
} from "./form-validation";
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
afterEach(() => vi.useRealTimers());
describe("bank form input", () => {
@@ -16,6 +9,7 @@ describe("bank form input", () => {
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
true,
);
for (const invalid of [
"92233720368547758.08",
"1.001",
@@ -26,7 +20,9 @@ describe("bank form input", () => {
]) {
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
}
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
const values = {
bankName: "",
lastFour: "",
@@ -34,6 +30,7 @@ describe("bank form input", () => {
openingBalance: "-92233720368547758.08",
openingDate: "2026-01-01",
};
expect(bankCardSchema.safeParse(values).success).toBe(true);
expect(
bankCardSchema.safeParse({
@@ -62,6 +59,7 @@ it("converts local date input without accepting impossible or unfinished dates",
expect(
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
).toBe(true);
for (const value of ["31.02.2026", "07.08.", ""]) {
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
false,
+13 -79
View File
@@ -1,28 +1,25 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
const ISO_DATE_LENGTH = 10;
const ISO_MONTH_LENGTH = 7;
const MAX_CARD_NAME_LENGTH = 100;
function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
@@ -31,84 +28,21 @@ function bankToday() {
year: "numeric",
}).format(new Date());
}
function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null;
const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT);
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
// eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null.
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
const moneySchema = z.string().refine(
(value) => {
const cents = moneyCents(value);
return (
cents !== null
&& cents >= MIN_BALANCE_KOPECKS
&& cents <= MAX_BALANCE_KOPECKS
);
},
{ error: validationMessage("bank.validation.money") },
);
const positiveMoneySchema = moneySchema.refine(
(value) => {
const cents = moneyCents(value);
return cents === null || cents > 0n;
},
{ error: validationMessage("bank.validation.positive") },
);
const bankDateSchema = z
.string()
.refine(
value =>
/^\d{4}-\d{2}-\d{2}$/.test(value)
&& 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") },
);
const bankCardSchema = z.object({
bankName: z
.string()
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
name: z
.string()
.trim()
.min(1, validationMessage("bank.validation.name")(undefined))
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined),
),
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
});
type BankCardValues = z.infer<typeof bankCardSchema>;
export {
bankCardSchema,
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankMonth,
bankToday,
moneyCents,
positiveMoneySchema,
};
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
@@ -0,0 +1,81 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
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;
const moneySchema = z.string().refine(
(value) => {
const cents = moneyCents(value);
return (
cents !== null
&& cents >= MIN_BALANCE_KOPECKS
&& cents <= MAX_BALANCE_KOPECKS
);
},
{ error: validationMessage("bank.validation.money") },
);
const positiveMoneySchema = moneySchema.refine(
(value) => {
const cents = moneyCents(value);
return cents === null || cents > 0n;
},
{ error: validationMessage("bank.validation.positive") },
);
const bankDateSchema = z
.string()
.refine(
value =>
/^\d{4}-\d{2}-\d{2}$/.test(value)
&& 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") },
);
const bankCardSchema = z.object({
bankName: z
.string()
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
name: z
.string()
.trim()
.min(1, validationMessage("bank.validation.name")(undefined))
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined),
),
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
});
type BankCardValues = z.infer<typeof bankCardSchema>;
export {
bankCardSchema,
type BankCardValues,
bankDateSchema,
positiveMoneySchema,
};
@@ -1,124 +1,132 @@
import type { OperationIconProps } from "@wealthledger/icons";
import type { BankOperation } from "../api/queries";
type OperationIcon = { animation?: string; color: string; path: string };
type OperationIcon = {
animation?: string;
color: string;
variant: OperationIconProps["variant"];
};
const categoryIcons: Record<string, OperationIcon> = {
"алкоголь": {
animation: "wine",
color: "grape",
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
variant: "wine",
},
"вклады": {
animation: "deposit",
color: "green",
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
variant: "deposit",
},
"жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" },
"здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" },
"жилье": { color: "orange", variant: "home" },
"здоровье": { color: "red", variant: "medical" },
"инвестиции и накопления": {
animation: "growth",
color: "green",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
variant: "growth",
},
"кафе": {
animation: "coffee",
color: "orange",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
variant: "coffee",
},
"образование": {
color: "blue",
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
variant: "education",
},
"переводы": {
animation: "transfer",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
variant: "transfer",
},
"погашение кредита": {
animation: "repayment",
color: "orange",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
variant: "repayment",
},
"подписки": {
color: "grape",
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
variant: "subscriptions",
},
"покупки": {
color: "grape",
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
variant: "shopping",
},
"продукты": {
animation: "basket",
color: "green",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
variant: "basket",
},
"путешествия": {
color: "blue",
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
variant: "travel",
},
"развлечения": {
color: "grape",
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
variant: "music",
},
"связь и интернет": {
animation: "wifi",
color: "blue",
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
variant: "wifi",
},
"снятие наличных": {
color: "green",
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
variant: "cashWithdrawal",
},
"ставки": {
color: "grape",
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
variant: "dice",
},
"страхование": {
color: "green",
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
variant: "shield",
},
"табак": {
color: "orange",
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
variant: "smoking",
},
"такси": {
color: "yellow",
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
variant: "taxi",
},
"транспорт": {
animation: "car",
color: "blue",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
variant: "car",
},
"хостинг и ии-сервисы": {
animation: "server",
color: "grape",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
variant: "server",
},
"цифровые покупки": {
color: "blue",
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
variant: "download",
},
};
const otherIcon: OperationIcon = {
animation: "grid",
color: "gray",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
variant: "grid",
};
const operationIcons: Record<
Exclude<BankOperation["kind"], "expense">,
OperationIcon
> = {
refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" },
refund: { color: "teal", variant: "refund" },
topup: {
animation: "topup",
color: "green",
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
variant: "topup",
},
transfer: {
animation: "transfer",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
variant: "transfer",
},
};
@@ -126,12 +134,18 @@ function bankOperationAppearance(
kind: BankOperation["kind"],
categoryName?: null | string,
) {
if (kind !== "expense") return /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ operationIcons[kind];
if (kind !== "expense")
// eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
return operationIcons[kind];
const name = categoryName
?.trim()
.toLocaleLowerCase("ru-RU")
.replaceAll("ё", "е");
return name && Object.hasOwn(categoryIcons, name) ? /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ categoryIcons[name] : otherIcon;
return name && Object.hasOwn(categoryIcons, name)
? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn.
: otherIcon;
}
export { bankOperationAppearance };
@@ -17,6 +17,7 @@ it.each([
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01",
+3 -2
View File
@@ -1,15 +1,16 @@
import { z } from "zod";
import { calendarDate } from "@/shared/lib";
import { bankMonth } from "./form-validation";
const calendarDate = z.iso.date();
const ISO_DATE_LENGTH = 10;
function bankPeriod(parameters: URLSearchParams) {
const month = bankMonth(parameters.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = parameters.get("from");
const to = parameters.get("to");
return from
&& to
&& calendarDate.safeParse(from).success