feat(contacts): cari kişiler, hesap ↔ cari transferleri, cari ekstresi ve vade uyarısı, 007 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
parent dfb83d6fd4
commit 5d370a062e
18 changed files with 1060 additions and 26 deletions
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { contactDelta, dueState, isOutgoing } from "./contacts";
import { addDays } from "./recurrence";
describe("cari", () => {
it("yön ve bakiye etkisi", () => {
expect(isOutgoing("lend")).toBe(true);
expect(isOutgoing("repay")).toBe(true);
expect(isOutgoing("borrow")).toBe(false);
expect(contactDelta("lend", 100)).toBe(100);
expect(contactDelta("repay", 100)).toBe(100);
expect(contactDelta("borrow", 100)).toBe(-100);
expect(contactDelta("collect", 100)).toBe(-100);
});
it("vade durumu", () => {
const today = "2026-11-05";
expect(dueState(100, null, 3, today, addDays)).toBe("none");
expect(dueState(0, "2026-11-01", 3, today, addDays)).toBe("none");
expect(dueState(-100, "2026-11-04", 3, today, addDays)).toBe("overdue");
expect(dueState(100, "2026-11-08", 3, today, addDays)).toBe("upcoming");
expect(dueState(100, "2026-11-09", 3, today, addDays)).toBe("none");
expect(dueState(100, "2026-11-05", 0, today, addDays)).toBe("upcoming");
});
});
+37
View File
@@ -0,0 +1,37 @@
/** Cari hesaplar (Bölüm 6.4). */
export const CONTACT_RELATIONS = ["family", "friend", "neighbor", "work", "other"] as const;
export type ContactRelation = (typeof CONTACT_RELATIONS)[number];
/**
* Cari hareket türleri. Hesaptan cariye (çıkış): borç verdim, borcumu ödedim.
* Cariden hesaba (giriş): borç aldım, tahsilat yaptım. Hepsi transferdir; gelir/gider değildir.
*/
export const CONTACT_ENTRY_KINDS = ["lend", "repay", "borrow", "collect"] as const;
export type ContactEntryKind = (typeof CONTACT_ENTRY_KINDS)[number];
/** Para hesaptan cariye mi gidiyor? */
export const isOutgoing = (kind: ContactEntryKind) => kind === "lend" || kind === "repay";
/**
* Cari bakiyesi (kuruş): + "Bana borçlu", − "Ben borçluyum".
* Çıkışlar (verdim/ödedim) bakiyeyi artırır, girişler (aldım/tahsil ettim) azaltır.
*/
export function contactDelta(kind: ContactEntryKind, amountMinor: number): number {
return isOutgoing(kind) ? amountMinor : -amountMinor;
}
export type DueState = "none" | "upcoming" | "overdue";
/** Vade uyarısı: bakiye kapanmamışsa, vade geçtiyse "overdue", hatırlatma penceresindeyse "upcoming". */
export function dueState(
balanceMinor: number,
dueDate: string | null,
reminderDays: number,
today: string,
addDays: (ymd: string, n: number) => string,
): DueState {
if (!dueDate || balanceMinor === 0) return "none";
if (dueDate < today) return "overdue";
return addDays(today, reminderDays) >= dueDate ? "upcoming" : "none";
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { CONTACT_ENTRY_KINDS, CONTACT_RELATIONS } from "../contacts";
import { parseMoney } from "../money";
import { moneyInputSchema } from "./account";
const v = tr.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const optionalYmd = z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable());
const reminderDays = z.coerce.number().int().min(0).max(60).default(3);
/** Cari kişi formu (web + mobil). */
export const contactFormSchema = z.object({
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
relation: z.enum(CONTACT_RELATIONS).default("other"),
phone: z
.string()
.trim()
.max(30)
.regex(/^[0-9+()\s-]*$/, v.phone)
.default(""),
note: z.string().trim().max(500).default(""),
/** + bana borçlu, − ben borçluyum. */
openingBalance: z.string().default("").pipe(moneyInputSchema),
openingDate: ymd,
isPrivate: z.boolean().default(false),
dueDate: optionalYmd,
reminderDays,
});
export type ContactFormInput = z.input<typeof contactFormSchema>;
export type ContactForm = z.output<typeof contactFormSchema>;
/** Cari hareketi: hesaptan cariye veya cariden hesaba transfer. */
export const contactEntrySchema = z.object({
kind: z.enum(CONTACT_ENTRY_KINDS),
accountId: z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount),
amount: z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor <= 0) {
ctx.addIssue({
code: "custom",
message: minor === null ? v.money : tr.transactions.validation.amountPositive,
});
return z.NEVER;
}
return minor;
}),
date: ymd,
description: z.string().trim().max(500).default(""),
/** Verilirse carinin vadesi güncellenir (boş string → vade kaldırılır). */
dueDate: optionalYmd.optional(),
reminderDays: reminderDays.optional(),
});
export type ContactEntryInput = z.input<typeof contactEntrySchema>;
export type ContactEntry = z.output<typeof contactEntrySchema>;
+12 -10
View File
@@ -13,7 +13,8 @@ export type EffectInput = {
type: TransactionType;
status: TransactionStatus;
amountMinor: number;
accountId: string;
/** Cariden girişte (borç alma, tahsilat) boştur. */
accountId: string | null;
toAccountId?: string | null;
deleted?: boolean;
};
@@ -27,16 +28,17 @@ export function transactionEffects(t: EffectInput): { accountId: string; deltaMi
if (t.status !== "posted" || t.deleted) return [];
switch (t.type) {
case "income":
return [{ accountId: t.accountId, deltaMinor: t.amountMinor }];
return t.accountId ? [{ accountId: t.accountId, deltaMinor: t.amountMinor }] : [];
case "expense":
return [{ accountId: t.accountId, deltaMinor: -t.amountMinor }];
case "transfer":
return t.toAccountId
? [
{ accountId: t.accountId, deltaMinor: -t.amountMinor },
{ accountId: t.toAccountId, deltaMinor: t.amountMinor },
]
: [];
return t.accountId ? [{ accountId: t.accountId, deltaMinor: -t.amountMinor }] : [];
case "transfer": {
// Hesap ↔ hesap; cari transferinde bir taraf boştur (cariye çıkış: toAccountId yok,
// cariden giriş: accountId yok).
const out: { accountId: string; deltaMinor: number }[] = [];
if (t.accountId) out.push({ accountId: t.accountId, deltaMinor: -t.amountMinor });
if (t.toAccountId) out.push({ accountId: t.toAccountId, deltaMinor: t.amountMinor });
return out;
}
}
}