feat(web): Zod DTO schemas mirroring backend + fixtures

This commit is contained in:
Munkherdene 2026-08-22 20:09:04 +08:00
parent 0275aeaaaf
commit 6454bc0a51
16 changed files with 189 additions and 0 deletions

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const NamedSchema = z.object({ name: z.string(), count: z.number(), total: z.string() });
export const MonthSchema = z.object({ month: z.string(), income: z.string(), expense: z.string() });
export const AnalyzeSchema = z.object({ from: z.string(), to: z.string(), income: z.string(), expense: z.string(),
discretionaryExpense: z.string().nullish(), net: z.string(), months: z.array(MonthSchema).nullish(),
expenseCategories: z.array(NamedSchema).nullish(), incomePayees: z.array(NamedSchema).nullish(),
expensePayees: z.array(NamedSchema).nullish() });
export type Named = z.infer<typeof NamedSchema>;
export type Analyze = z.infer<typeof AnalyzeSchema>;

13
src/api/schemas/budget.ts Normal file
View file

@ -0,0 +1,13 @@
import { z } from "zod";
export const LimitRowSchema = z.object({ category: z.string(), spent: z.string(), limit: z.string() });
export const HorizonReportSchema = z.object({ overallSpent: z.string(), overallLimit: z.string(), rows: z.array(LimitRowSchema) });
export const CategoryLimitSchema = z.object({ name: z.string(), day: z.string(), week: z.string(), month: z.string() });
export const SavingsGoalSchema = z.object({ name: z.string(), target: z.string(), monthlyContribution: z.string(),
accountId: z.number(), accountName: z.string(), saved: z.string(), targetDate: z.string() });
export const BudgetSchema = z.object({ dayLimit: z.string(), weekLimit: z.string(), monthLimit: z.string(),
plannedIncome: z.string(), plannedIncomeManual: z.string(), loanObligations: z.string(),
savingsContributions: z.string().nullish(), subscriptionContributions: z.string().nullish(),
availableIncome: z.string(), categories: z.array(CategoryLimitSchema), savingsGoals: z.array(SavingsGoalSchema).nullish(),
report: z.object({ day: HorizonReportSchema, week: HorizonReportSchema, month: HorizonReportSchema }) });
export type Budget = z.infer<typeof BudgetSchema>;
export type SavingsGoal = z.infer<typeof SavingsGoalSchema>;

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const CategorySchema = z.object({
name: z.string(),
kind: z.string(),
depth: z.number(),
icon: z.string().nullish(),
});
export type Category = z.infer<typeof CategorySchema>;

View file

@ -0,0 +1,7 @@
import { z } from "zod";
export const ConnectionSchema = z.object({
bank: z.string(),
username: z.string(),
courierManaged: z.boolean(),
});
export type Connection = z.infer<typeof ConnectionSchema>;

11
src/api/schemas/index.ts Normal file
View file

@ -0,0 +1,11 @@
export * from "./networth";
export * from "./analyze";
export * from "./budget";
export * from "./transaction";
export * from "./lending";
export * from "./settings";
export * from "./connection";
export * from "./category";
export * from "./subscription";
export * from "./manualAsset";
export * from "./user";

View file

@ -0,0 +1,7 @@
import { z } from "zod";
export const LendingRepaymentSchema = z.object({ id: z.number(), amount: z.string(), paidOn: z.string(),
note: z.string(), txnId: z.number().nullish() });
export const LendingSchema = z.object({ id: z.number(), person: z.string(), principal: z.string(), lentOn: z.string(),
dueOn: z.string(), note: z.string(), repaid: z.string(), remaining: z.string(), status: z.string(), overdue: z.boolean(),
txnId: z.number().nullish(), repayments: z.array(LendingRepaymentSchema) });
export type Lending = z.infer<typeof LendingSchema>;

View file

@ -0,0 +1,50 @@
import { z } from "zod";
export const ManualAssetSchema = z.object({
name: z.string(),
category: z.string(),
value: z.string(),
acquiredValue: z.string(),
currency: z.string(),
condition: z.string(),
isLiability: z.boolean(),
change: z.string(),
});
export type ManualAsset = z.infer<typeof ManualAssetSchema>;
export const AssetPointSchema = z.object({
value: z.string(),
recordedAt: z.string(),
low: z.string().nullish(),
high: z.string().nullish(),
count: z.number().nullish(),
source: z.string().nullish(),
kind: z.string().nullish(),
});
export type AssetPoint = z.infer<typeof AssetPointSchema>;
export const ListingFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
export type ListingField = z.infer<typeof ListingFieldSchema>;
export const AssetListingSchema = z.object({
title: z.string(),
price: z.string(),
url: z.string(),
description: z.string().nullish(),
fields: z.array(ListingFieldSchema).nullish(),
images: z.array(z.string()).nullish(),
source: z.string().nullish(),
});
export type AssetListing = z.infer<typeof AssetListingSchema>;
export const RevalueResultSchema = z.object({
value: z.string(),
low: z.string(),
high: z.string(),
source: z.string(),
count: z.number(),
});
export type RevalueResult = z.infer<typeof RevalueResultSchema>;

View file

@ -0,0 +1,7 @@
import { z } from "zod";
export const AccountSchema = z.object({ accountId: z.number(), bank: z.string(), accountNumber: z.string(),
currency: z.string(), isLiability: z.boolean(), balance: z.string(), asOf: z.string().nullish() });
export const NetWorthSchema = z.object({ assets: z.string(), liabilities: z.string(), total: z.string(),
accounts: z.array(AccountSchema).nullish() });
export type Account = z.infer<typeof AccountSchema>;
export type NetWorth = z.infer<typeof NetWorthSchema>;

View file

@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { NetWorthSchema, AnalyzeSchema, BudgetSchema, TxnSchema, LendingSchema } from "./index";
import * as fx from "../../test/fixtures";
describe("schemas parse backend fixtures", () => {
it("networth", () => expect(() => NetWorthSchema.parse(fx.netWorth)).not.toThrow());
it("analyze", () => expect(() => AnalyzeSchema.parse(fx.analyzeMonth)).not.toThrow());
it("budget", () => expect(() => BudgetSchema.parse(fx.budget)).not.toThrow());
it("transaction", () => expect(() => TxnSchema.parse(fx.txn)).not.toThrow());
it("lending", () => expect(() => LendingSchema.parse(fx.lending)).not.toThrow());
it("rejects wrong shape", () => expect(() => NetWorthSchema.parse({})).toThrow());
});

View file

@ -0,0 +1,11 @@
import { z } from "zod";
export const SettingsSchema = z.object({
holderName: z.string(),
employer: z.string(),
salaryKeywords: z.array(z.string()),
payDays: z.array(z.number()),
ownAccounts: z.array(z.string()),
peerAccounts: z.array(z.string()),
hideAmounts: z.boolean().nullish(),
});
export type Settings = z.infer<typeof SettingsSchema>;

View file

@ -0,0 +1,15 @@
import { z } from "zod";
// Wire JSON field is "id" (Swift-side property name is `subId`, mapped via
// CodingKeys); the schema key stays "id" to match the actual JSON payload.
export const SubscriptionSchema = z.object({
label: z.string(),
amount: z.string(),
monthly: z.string(),
cadence: z.string(),
nextDue: z.string().nullish(),
matchKey: z.string().nullish(),
id: z.number().nullish(),
manual: z.boolean().nullish(),
category: z.string().nullish(),
});
export type Subscription = z.infer<typeof SubscriptionSchema>;

View file

@ -0,0 +1,5 @@
import { z } from "zod";
export const TxnSchema = z.object({ date: z.string(), amount: z.string(), direction: z.string(), category: z.string(),
title: z.string(), balanceAfter: z.string().nullish(), accountId: z.number(), transfer: z.boolean().nullish(),
txnId: z.number().nullish(), note: z.string().nullish(), matchKey: z.string().nullish(), salary: z.boolean().nullish() });
export type Txn = z.infer<typeof TxnSchema>;

13
src/api/schemas/user.ts Normal file
View file

@ -0,0 +1,13 @@
import { z } from "zod";
export const UserSchema = z.object({
id: z.number(),
email: z.string(),
createdAt: z.string(),
});
export type User = z.infer<typeof UserSchema>;
export const AuthResponseSchema = z.object({
token: z.string(),
user: UserSchema,
});
export type AuthResponse = z.infer<typeof AuthResponseSchema>;

14
src/test/fixtures.ts Normal file
View file

@ -0,0 +1,14 @@
export const netWorth = { assets: "452200", liabilities: "0", total: "452200",
accounts: [{ accountId: 1, bank: "khan", accountNumber: "***1", currency: "MNT", isLiability: false, balance: "452200", asOf: "2026-08-01" }] };
export const analyzeMonth = { from: "2026-08-01", to: "2026-08-31", income: "1345634", expense: "1200000",
discretionaryExpense: "800000", net: "145634", months: null, expenseCategories: [{ name: "Хоол", count: 12, total: "23700" }],
incomePayees: [{ name: "Цалин", count: 1, total: "1345634" }], expensePayees: [{ name: "худалдан авалт", count: 3, total: "52000" }] };
export const budget = { dayLimit: "35500", weekLimit: "0", monthLimit: "900000", plannedIncome: "2036795",
plannedIncomeManual: "0", loanObligations: "0", savingsContributions: "0", subscriptionContributions: "0", availableIncome: "2036795",
categories: [{ name: "Хоол", day: "27000", week: "0", month: "0" }], savingsGoals: [],
report: { day: { overallSpent: "15500", overallLimit: "35500", rows: [{ category: "Хоол", spent: "23700", limit: "27000" }] },
week: { overallSpent: "0", overallLimit: "0", rows: [] }, month: { overallSpent: "1200000", overallLimit: "900000", rows: [] } } };
export const txn = { date: "2026-08-01", amount: "52000", direction: "debit", category: "Хоол", title: "худалдан авалт",
balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false };
export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "",
repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] };

6
vitest.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], globals: true },
});

1
vitest.setup.ts Normal file
View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";