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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user