refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user