feat(in-kind): varlık türleri, fiyat tablosu, aynî borç/alacak, birikim planı ve ödeme akışı, 008 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:37:48 +03:00
parent 1062b2d96c
commit 9781cd9ed2
15 changed files with 1354 additions and 3 deletions
+116
View File
@@ -0,0 +1,116 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { ASSET_KEYS } from "../assets";
import { parseMoney } from "../money";
import { parseQuantity } from "../quantity";
const v = tr.validation;
const k = tr.inKind.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
/** Boş → null; dolu → pozitif kuruş. */
const optionalMoney = z.preprocess(
emptyToNull,
z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor < 0) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
})
.nullable(),
);
const quantity = z
.string()
.trim()
.transform((s, ctx) => {
const q = parseQuantity(s);
if (q === null || q <= 0) {
ctx.addIssue({ code: "custom", message: k.quantity });
return z.NEVER;
}
return q;
});
/** Fiyat girişi: satış boşsa alışla aynı (tek fiyat girilirse ikisine de yazılır — Faz 0 kararı). */
export const assetPriceFormSchema = z
.object({
assetKey: z.enum(ASSET_KEYS),
date: ymd,
buy: optionalMoney,
sell: optionalMoney,
})
.superRefine((x, ctx) => {
if (x.buy === null && x.sell === null)
ctx.addIssue({ code: "custom", path: ["buy"], message: k.price });
})
.transform((x) => ({
assetKey: x.assetKey,
date: x.date,
buyMinor: (x.buy ?? x.sell)!,
sellMinor: (x.sell ?? x.buy)!,
}));
export type AssetPriceForm = z.output<typeof assetPriceFormSchema>;
/** Aynî borç/alacak kaydı. Birim fiyat boşsa fiyat tablosundan (o tarihteki son fiyat) alınır. */
export const inKindFormSchema = z.object({
direction: z.enum(["debt", "receivable"]),
assetKey: z.enum(ASSET_KEYS),
quantity,
date: ymd,
unitPrice: optionalMoney,
note: z.string().trim().max(500).default(""),
savingsAccountId: z.preprocess(emptyToNull, objectId.nullable()),
monthlyPlan: optionalMoney,
});
export type InKindForm = z.output<typeof inKindFormSchema>;
/** Birikim katkısı: hesaptan borcun birikim hesabına transfer. */
export const contributionSchema = z.object({
fromAccountId: objectId,
amount: z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: tr.transactions.validation.amountPositive });
return z.NEVER;
}
return minor;
}),
date: ymd,
});
export type ContributionForm = z.output<typeof contributionSchema>;
/**
* "Ödeme gerçekleşti". Aynı varlıkla: miktar zorunlu, TL (varlığı almak için harcanan) isteğe
* bağlı. TL karşılığıyla: tutar ve hesap zorunlu; miktar boşsa güncel fiyattan hesaplanır.
*/
export const paymentFormSchema = z
.object({
method: z.enum(["asset", "cash"]),
date: ymd,
quantity: z.preprocess(emptyToNull, quantity.nullable()),
amount: optionalMoney,
accountId: z.preprocess(emptyToNull, objectId.nullable()),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.method === "asset" && x.quantity === null)
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
if (x.method === "cash") {
if (!x.amount) ctx.addIssue({ code: "custom", path: ["amount"], message: k.amount });
if (!x.accountId) ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
}
if (x.amount && !x.accountId)
ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
});
export type PaymentForm = z.output<typeof paymentFormSchema>;