diff --git a/src/api/schemas/analyze.ts b/src/api/schemas/analyze.ts new file mode 100644 index 0000000..fa8e39d --- /dev/null +++ b/src/api/schemas/analyze.ts @@ -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; +export type Analyze = z.infer; diff --git a/src/api/schemas/budget.ts b/src/api/schemas/budget.ts new file mode 100644 index 0000000..80c1ae5 --- /dev/null +++ b/src/api/schemas/budget.ts @@ -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; +export type SavingsGoal = z.infer; diff --git a/src/api/schemas/category.ts b/src/api/schemas/category.ts new file mode 100644 index 0000000..197db2e --- /dev/null +++ b/src/api/schemas/category.ts @@ -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; diff --git a/src/api/schemas/connection.ts b/src/api/schemas/connection.ts new file mode 100644 index 0000000..50d9c3c --- /dev/null +++ b/src/api/schemas/connection.ts @@ -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; diff --git a/src/api/schemas/index.ts b/src/api/schemas/index.ts new file mode 100644 index 0000000..779da1a --- /dev/null +++ b/src/api/schemas/index.ts @@ -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"; diff --git a/src/api/schemas/lending.ts b/src/api/schemas/lending.ts new file mode 100644 index 0000000..a94d625 --- /dev/null +++ b/src/api/schemas/lending.ts @@ -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; diff --git a/src/api/schemas/manualAsset.ts b/src/api/schemas/manualAsset.ts new file mode 100644 index 0000000..3fe6b55 --- /dev/null +++ b/src/api/schemas/manualAsset.ts @@ -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; + +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; + +export const ListingFieldSchema = z.object({ + name: z.string(), + value: z.string(), +}); +export type ListingField = z.infer; + +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; + +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; diff --git a/src/api/schemas/networth.ts b/src/api/schemas/networth.ts new file mode 100644 index 0000000..8abe012 --- /dev/null +++ b/src/api/schemas/networth.ts @@ -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; +export type NetWorth = z.infer; diff --git a/src/api/schemas/schemas.test.ts b/src/api/schemas/schemas.test.ts new file mode 100644 index 0000000..2d23d0c --- /dev/null +++ b/src/api/schemas/schemas.test.ts @@ -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()); +}); diff --git a/src/api/schemas/settings.ts b/src/api/schemas/settings.ts new file mode 100644 index 0000000..63bdb53 --- /dev/null +++ b/src/api/schemas/settings.ts @@ -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; diff --git a/src/api/schemas/subscription.ts b/src/api/schemas/subscription.ts new file mode 100644 index 0000000..be49e88 --- /dev/null +++ b/src/api/schemas/subscription.ts @@ -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; diff --git a/src/api/schemas/transaction.ts b/src/api/schemas/transaction.ts new file mode 100644 index 0000000..b9124af --- /dev/null +++ b/src/api/schemas/transaction.ts @@ -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; diff --git a/src/api/schemas/user.ts b/src/api/schemas/user.ts new file mode 100644 index 0000000..07f11b1 --- /dev/null +++ b/src/api/schemas/user.ts @@ -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; + +export const AuthResponseSchema = z.object({ + token: z.string(), + user: UserSchema, +}); +export type AuthResponse = z.infer; diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..acd857f --- /dev/null +++ b/src/test/fixtures.ts @@ -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 }] }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..643bb3b --- /dev/null +++ b/vitest.config.ts @@ -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 }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest";