feat: add manual bank card screens
Add cards, operations, refunds, categories and monthly summaries with Mantine date controls. Derive banking types from runtime OpenAPI. Cover recovery and session isolation.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
bankToday,
|
||||
positiveMoneySchema,
|
||||
} from "./form-validation";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("bank form input", () => {
|
||||
it("keeps input bounds exact without rounding and canonicalizes leading zeros", () => {
|
||||
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
|
||||
true,
|
||||
);
|
||||
for (const invalid of [
|
||||
"92233720368547758.08",
|
||||
"1.001",
|
||||
"0",
|
||||
"-1",
|
||||
"abc",
|
||||
"1e3",
|
||||
]) {
|
||||
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
|
||||
}
|
||||
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
|
||||
const values = {
|
||||
name: "A",
|
||||
bankName: "",
|
||||
lastFour: "",
|
||||
openingBalance: "-92233720368547758.08",
|
||||
openingDate: "2026-01-01",
|
||||
};
|
||||
expect(bankCardSchema.safeParse(values).success).toBe(true);
|
||||
expect(
|
||||
bankCardSchema.safeParse({
|
||||
...values,
|
||||
openingBalance: "-92233720368547758.09",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
bankCardSchema.safeParse({ ...values, lastFour: "123" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("uses Moscow date and refuses nonexistent or future calendar days", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-31T21:30:00Z"));
|
||||
expect(bankToday()).toBe("2026-09-01");
|
||||
expect(bankMonth(null)).toBe("2026-09");
|
||||
expect(bankMonth("2026-13")).toBe("2026-09");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
function bankToday() {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Moscow",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
}
|
||||
function bankMonth(value: string | null) {
|
||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
||||
? value
|
||||
: bankToday().slice(0, 7);
|
||||
}
|
||||
function bankMoneyValue(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(",", ".")
|
||||
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||
}
|
||||
function moneyCents(value: string): bigint | null {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!/^-?\d+(\.\d{1,2})?$/.test(normalized)) return null;
|
||||
const [whole, fraction = ""] = normalized.replace("-", "").split(".");
|
||||
return (
|
||||
BigInt(`${whole}${fraction.padEnd(2, "0")}`) *
|
||||
(normalized.startsWith("-") ? -1n : 1n)
|
||||
);
|
||||
}
|
||||
const moneySchema = z.string().refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return (
|
||||
cents !== null &&
|
||||
cents >= -9223372036854775808n &&
|
||||
cents <= 9223372036854775807n
|
||||
);
|
||||
}, validationMessage("bank.validation.money"));
|
||||
const positiveMoneySchema = moneySchema.refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return cents === null || cents > 0n;
|
||||
}, 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, 10) === value &&
|
||||
value <= bankToday(),
|
||||
validationMessage("bank.validation.date"),
|
||||
);
|
||||
const bankCardSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, validationMessage("bank.validation.name")(undefined))
|
||||
.max(100, validationMessage("bank.validation.name")(undefined)),
|
||||
bankName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, validationMessage("bank.validation.bankName")(undefined)),
|
||||
lastFour: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value || /^\d{4}$/.test(value),
|
||||
validationMessage("bank.validation.lastFour"),
|
||||
),
|
||||
openingBalance: moneySchema,
|
||||
openingDate: bankDateSchema,
|
||||
});
|
||||
type BankCardValues = z.infer<typeof bankCardSchema>;
|
||||
|
||||
export {
|
||||
type BankCardValues,
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
bankToday,
|
||||
moneyCents,
|
||||
positiveMoneySchema,
|
||||
};
|
||||
Reference in New Issue
Block a user