feat: Faz 8a - cari hesaplar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
34 changed files with 2220 additions and 38 deletions
+19
View File
@@ -6,6 +6,25 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
Faz 8 (alt fazlar halinde; `v0.8.0` Faz 8b sonunda).
### Eklendi — Faz 8a: cari hesaplar
- Cari kişiler (`contacts`): ad, yakınlık, telefon, not, açılış bakiyesi (+ bana borçlu / − ben borçluyum), vade tarihi ve hatırlatma günü; arşivleme; aynı adda iki aktif cari engellenir (Türkçe normalize).
- "Yalnızca ben" cari: kişi ve hareketleri yalnızca sahibine görünür; ortak hesabın ekstresinde bu hareketler görünür ama kişi adı "Gizli cari" olarak maskelenir.
- Cari hareketleri: "Borç verdim", "Borç ödedim" (hesaptan cariye), "Borç aldım", "Tahsilat" (cariden hesaba). Hepsi transferdir: hesap bakiyesi değişir, gelir/gider toplamlarına girmez. Kısmi ödeme bakiyeyle doğal olarak desteklenir.
- `/cari`: bana borçlu / ben borçluyum / net özeti, cari listesi (bakiye, son hareket, vade rozeti), yeni cari, hareket ekle, düzenle, arşivle.
- `/cari/:id`: cari ekstresi (tarih sıralı hareketler ve yürüyen bakiye, açılış satırı); hareketi düzenleme ve geri alınabilir silme.
- Vade uyarısı (uygulama içi): vade geçtiyse veya hatırlatma penceresindeyse cari listesinde rozet ve dashboard'da uyarı.
- İşlem listesi ve ayrıntısında cari hareketleri kişi adı ve hareket türüyle; ayrıntıdan cariye bağlantı. CSV'de kategori sütununda "Cari: Ad (tür)".
- Seed: 5 örnek cari (vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş alacak, "yalnızca ben" cari, Annemler hanesinde bir borç).
- `007` migration'ı: `contacts` index'leri, işlemlerde cari alanları. Denetim kaydı: cari arşivleme.
- Testler: yön/bakiye ve vade durumu (birim); defter, bakiye ve gelir/gider ayrımı, açılış bakiyesi, ekstre, tür değiştirme, vade uyarısı, çift ad, arşiv, yetki, başka hanenin hesabı ve gizli cari izolasyonu (entegrasyon).
### Değişti — Faz 8a
- İşlemlerde kaynak hesap (`accountId`) artık boş olabilir: cariden girişte para yalnızca hedef hesaba (`toAccountId`) girer. Bakiye, ekstre, kart ve KMH hesapları ek kural olmadan doğru çalışır.
## [0.7.0] - 2026-11-05
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
+1
View File
@@ -319,6 +319,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
---
+15
View File
@@ -0,0 +1,15 @@
import { ContactModel } from "@/models/contact";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
export const migration: Migration = {
name: "007-contacts",
description: "contacts index'leri; işlemlerde cari alanları ve cari index'i",
async up(db) {
await db
.collection("transactions")
.updateMany({ contactKind: { $exists: false } }, { $set: { contactKind: null } });
await ContactModel.createIndexes();
await TransactionModel.createIndexes();
},
};
+2 -1
View File
@@ -6,10 +6,11 @@ import { migration as m003 } from "./003-transactions";
import { migration as m004 } from "./004-attachments-reconciliation";
import { migration as m005 } from "./005-recurring";
import { migration as m006 } from "./006-card-statements";
import { migration as m007 } from "./007-contacts";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007];
const COLLECTION = "migrations";
+158
View File
@@ -0,0 +1,158 @@
import type { Types } from "mongoose";
import { createContact, createContactEntry } from "@/features/contacts/service";
import { AccountModel } from "@/models/account";
import { ContactModel } from "@/models/contact";
import type { ContactEntryKind, ContactRelation } from "@/shared/contacts";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { addDays } from "@/shared/recurrence";
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
/**
* Örnek cariler (Faz 8a): vadesi yaklaşan alacak, kısmen ödenen borç, vadesi geçmiş küçük alacak
* ve "yalnızca ben" cari. İdempotent: cari `seedRef` ile; hareketler yalnızca cari ilk
* oluşturulduğunda eklenir.
*/
type SeedContact = {
ref: string;
household: string;
by: string;
name: string;
relation: ContactRelation;
phone?: string;
isPrivate?: boolean;
dueInDays?: number;
reminderDays?: number;
entries: {
kind: ContactEntryKind;
account: string;
amountMinor: number;
daysAgo: number;
description?: string;
}[];
};
const CONTACTS: SeedContact[] = [
{
ref: "ahmet",
household: "Çakır Ailesi",
by: "admin",
name: "Ahmet Yılmaz",
relation: "friend",
phone: "0532 000 00 01",
dueInDays: 5,
reminderDays: 7,
entries: [
{
kind: "lend",
account: "Ziraat Maaş",
amountMinor: 5_000_00,
daysAgo: 70,
description: "Araba tamiri için",
},
{ kind: "collect", account: "Cüzdan", amountMinor: 2_000_00, daysAgo: 30 },
],
},
{
ref: "hasan",
household: "Çakır Ailesi",
by: "admin",
name: "Hasan Çakır",
relation: "family",
dueInDays: 60,
entries: [
{
kind: "borrow",
account: "Ziraat Maaş",
amountMinor: 20_000_00,
daysAgo: 120,
description: "Ev eşyası için",
},
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 60 },
{ kind: "repay", account: "Ziraat Maaş", amountMinor: 5_000_00, daysAgo: 10 },
],
},
{
ref: "elif",
household: "Çakır Ailesi",
by: "ayse",
name: "Elif (komşu)",
relation: "neighbor",
dueInDays: -2,
entries: [
{
kind: "lend",
account: "Cüzdan",
amountMinor: 300_00,
daysAgo: 12,
description: "Pazar alışverişi",
},
],
},
{
ref: "selin",
household: "Çakır Ailesi",
by: "ayse",
name: "Selin (iş arkadaşı)",
relation: "work",
isPrivate: true,
entries: [{ kind: "lend", account: "Cüzdan", amountMinor: 1_000_00, daysAgo: 8 }],
},
{
ref: "ali",
household: "Annemler",
by: "fatma",
name: "Ali Demir",
relation: "family",
dueInDays: 45,
entries: [{ kind: "borrow", account: "Vakıfbank Emekli", amountMinor: 3_000_00, daysAgo: 25 }],
},
];
export async function seedContacts(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const c of CONTACTS) {
const householdId = householdIds.get(c.household)!;
const seedRef = `contact:${c.ref}`;
if (await ContactModel.exists({ householdId, seedRef })) continue;
const by = userIds.get(c.by)!;
const oldest = Math.max(...c.entries.map((e) => e.daysAgo));
const r = await createContact(
by,
String(householdId),
contactFormSchema.parse({
name: c.name,
relation: c.relation,
phone: c.phone ?? "",
openingDate: addDays(today, -oldest - 1),
isPrivate: c.isPrivate ?? false,
dueDate: c.dueInDays !== undefined ? addDays(today, c.dueInDays) : null,
reminderDays: c.reminderDays ?? 3,
}),
);
if (!r.ok) throw new Error(`Seed carisi oluşturulamadı (${c.name}): ${r.error}`);
await ContactModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
for (const e of c.entries) {
const account = await AccountModel.findOne({ householdId, name: e.account }).lean();
const x = await createContactEntry(
by,
String(householdId),
r.data.id,
contactEntrySchema.parse({
kind: e.kind,
accountId: String(account!._id),
amount: formatMoneyInput(e.amountMinor),
date: addDays(today, -e.daysAgo),
description: e.description ?? "",
}),
);
if (!x.ok) throw new Error(`Seed cari hareketi oluşturulamadı (${c.name}): ${x.error}`);
}
created++;
}
return created;
}
+1
View File
@@ -47,6 +47,7 @@ describe("seed", () => {
transactions: 0,
recurringRules: 0,
installmentPlans: 0,
contacts: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+6
View File
@@ -10,6 +10,7 @@ import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts";
import { seedInstallments } from "./installments";
import { seedRecurring } from "./recurring";
import { seedTransactions } from "./transactions";
@@ -47,6 +48,7 @@ export type SeedSummary = {
transactions: number;
recurringRules: number;
installmentPlans: number;
contacts: number;
};
accounts: SeedAccountRow[];
};
@@ -64,6 +66,7 @@ const DOMAIN_COLLECTIONS = [
"reconciliations",
"recurringRules",
"statements",
"contacts",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -115,6 +118,7 @@ export async function runSeed({
transactions: 0,
recurringRules: 0,
installmentPlans: 0,
contacts: 0,
},
accounts: [],
};
@@ -289,6 +293,8 @@ export async function runSeed({
);
if (summary.created.installmentPlans)
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
summary.created.contacts = await seedContacts(householdIds, userIdMap);
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
// 7) Geliştirme hesapları tablosu
const all = [
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
import { getContactStatement } from "@/features/contacts/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.contacts.statement };
/** /cari/:id — cari ekstresi (gizli cari yalnızca sahibine; aksi 404). */
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const data = await getContactStatement(user.id, household.id, id);
if (!data) notFound();
return <ContactStatementView key={id} data={data} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { ContactsView } from "@/features/contacts/components/contacts-view";
import { listContacts } from "@/features/contacts/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.contacts.title };
export default async function ContactsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listContacts(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
<ContactsView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
</>
);
}
+4
View File
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
@@ -63,6 +64,9 @@ export default async function DashboardPage() {
<Suspense fallback={null}>
<OverdueBanner userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={null}>
<ContactsDueBanner userId={user.id} householdId={household.id} />
</Suspense>
<Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards />
</Suspense>
+2 -2
View File
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 7.2;
export const CURRENT_PHASE = 8.1;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -37,7 +37,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 },
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
+1 -1
View File
@@ -245,7 +245,7 @@ describe("işleme bağlama", () => {
cakir,
txId,
await form({
accountId: detail!.account.id,
accountId: detail!.account!.id,
categoryId: detail!.categoryId,
attachmentIds: [a3.id, a4.id],
}),
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
import {
createContact,
createContactEntry,
setContactArchived,
updateContact,
updateContactEntry,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function revalidate() {
revalidatePath("/", "layout");
}
export async function saveContactAction(
contactId: unknown,
input: unknown,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = contactFormSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
let result: ActionResult<unknown>;
if (contactId === null || contactId === undefined) {
result = await createContact(ctx.user.id, ctx.household.id, parsed.data);
} else {
const id = idSchema.safeParse(contactId);
if (!id.success) return fail(tr.common.notFound);
result = await updateContact(ctx.user.id, ctx.household.id, id.data, parsed.data);
}
if (result.ok) revalidate();
return result;
}
export async function setContactArchivedAction(
contactId: unknown,
archived: unknown,
): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(contactId);
const a = z.boolean().safeParse(archived);
if (!id.success || !a.success) return fail(tr.common.notFound);
const result = await setContactArchived(ctx.user.id, ctx.household.id, id.data, a.data);
if (result.ok) revalidate();
return result;
}
/** Yeni hareket (transactionId boş) veya mevcut hareketi düzenle. */
export async function saveContactEntryAction(
contactId: unknown,
transactionId: unknown,
input: unknown,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = contactEntrySchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
let result: ActionResult<unknown>;
if (transactionId) {
const tid = idSchema.safeParse(transactionId);
if (!tid.success) return fail(tr.common.notFound);
result = await updateContactEntry(ctx.user.id, ctx.household.id, tid.data, parsed.data);
} else {
const cid = idSchema.safeParse(contactId);
if (!cid.success) return fail(tr.common.notFound);
result = await createContactEntry(ctx.user.id, ctx.household.id, cid.data, parsed.data);
}
if (result.ok) revalidate();
return result;
}
@@ -0,0 +1,434 @@
"use client";
import { Loader2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
getTransactionFormOptionsAction,
setTransactionDeletedAction,
} from "@/features/transactions/actions";
import { AccountSelect } from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import {
CONTACT_ENTRY_KINDS,
CONTACT_RELATIONS,
isOutgoing,
type ContactEntryKind,
type ContactRelation,
} from "@/shared/contacts";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { saveContactAction, saveContactEntryAction } from "../actions";
import type { ContactView } from "../service";
const t = tr.contacts;
type Errors = Record<string, string | undefined>;
/** Cari oluştur / düzenle. */
export function ContactFormDialog({
open,
contact,
onOpenChange,
}: {
open: boolean;
contact: ContactView | null;
onOpenChange: (open: boolean) => void;
}) {
return open ? (
<ContactForm key={contact?.id ?? "new"} contact={contact} onClose={() => onOpenChange(false)} />
) : null;
}
function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) {
const [v, setV] = useState({
name: contact?.name ?? "",
relation: (contact?.relation ?? "friend") as ContactRelation,
phone: contact?.phone ?? "",
note: contact?.note ?? "",
openingBalance: contact?.openingBalanceMinor
? formatMoneyInput(contact.openingBalanceMinor)
: "",
openingDate: contact?.openingDate ?? todayIstanbul(),
dueDate: contact?.dueDate ?? "",
reminderDays: String(contact?.reminderDays ?? 3),
isPrivate: contact?.isPrivate ?? false,
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, value: (typeof v)[K]) =>
setV((x) => ({ ...x, [k]: value }));
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveContactAction(contact?.id ?? null, v);
setBusy(false);
if (r.ok) {
toast.success(contact ? t.toast.updated : t.toast.created);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={contact ? t.editTitle : t.createTitle}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="contact-form" disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
}
>
<form id="contact-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="ct-name">{f.name}</FieldLabel>
<Input
id="ct-name"
value={v.name}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
autoFocus
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ct-relation">{f.relation}</FieldLabel>
<Select
value={v.relation}
onValueChange={(x) => set("relation", x as ContactRelation)}
>
<SelectTrigger id="ct-relation" className="w-full">
<SelectValue>{t.relations[v.relation]}</SelectValue>
</SelectTrigger>
<SelectContent>
{CONTACT_RELATIONS.map((r) => (
<SelectItem key={r} value={r}>
{t.relations[r]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={Boolean(errors.phone)}>
<FieldLabel htmlFor="ct-phone">{f.phone}</FieldLabel>
<Input
id="ct-phone"
type="tel"
inputMode="tel"
value={v.phone}
onChange={(e) => set("phone", e.target.value)}
aria-invalid={Boolean(errors.phone)}
/>
{errors.phone ? <FieldError>{errors.phone}</FieldError> : null}
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.openingBalance)}>
<FieldLabel htmlFor="ct-opening">{f.openingBalance}</FieldLabel>
<MoneyInput
id="ct-opening"
value={v.openingBalance}
onChange={(x) => set("openingBalance", x)}
allowNegative
/>
{errors.openingBalance ? <FieldError>{errors.openingBalance}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="ct-opening-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="ct-opening-date"
type="date"
value={v.openingDate}
onChange={(e) => set("openingDate", e.target.value)}
/>
</Field>
</div>
<FieldDescription className="-mt-2">{f.openingHint}</FieldDescription>
<DueFields
dueDate={v.dueDate}
reminderDays={v.reminderDays}
onChange={(d, r) => setV((x) => ({ ...x, dueDate: d, reminderDays: r }))}
errors={errors}
/>
<Field>
<FieldLabel htmlFor="ct-note">{f.note}</FieldLabel>
<Textarea
id="ct-note"
rows={2}
value={v.note}
onChange={(e) => set("note", e.target.value)}
/>
</Field>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={v.isPrivate}
onCheckedChange={(x) => set("isPrivate", x === true)}
className="mt-0.5"
/>
{f.isPrivate}
</label>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function DueFields({
dueDate,
reminderDays,
onChange,
errors,
}: {
dueDate: string;
reminderDays: string;
onChange: (dueDate: string, reminderDays: string) => void;
errors: Errors;
}) {
const f = t.fields;
return (
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.dueDate)}>
<FieldLabel htmlFor="ct-due">{f.dueDate}</FieldLabel>
<Input
id="ct-due"
type="date"
value={dueDate}
onChange={(e) => onChange(e.target.value, reminderDays)}
aria-describedby="ct-due-hint"
/>
<FieldDescription id="ct-due-hint">{f.dueHint}</FieldDescription>
{errors.dueDate ? <FieldError>{errors.dueDate}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.reminderDays)}>
<FieldLabel htmlFor="ct-remind">{f.reminderDays}</FieldLabel>
<Input
id="ct-remind"
type="number"
inputMode="numeric"
min={0}
max={60}
value={reminderDays}
onChange={(e) => onChange(dueDate, e.target.value)}
disabled={!dueDate}
/>
</Field>
</div>
);
}
export type EntryMode = {
contact: ContactView;
entry?: TransactionListItem;
kind?: ContactEntryKind;
};
/** Cari hareketi ekle / düzenle (düzenlemede sil). */
export function ContactEntryDialog({
mode,
onClose,
}: {
mode: EntryMode | null;
onClose: () => void;
}) {
return mode ? (
<EntryForm key={mode.entry?.id ?? `new-${mode.kind ?? ""}`} mode={mode} onClose={onClose} />
) : null;
}
function EntryForm({ mode, onClose }: { mode: EntryMode; onClose: () => void }) {
const { contact, entry } = mode;
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
const [kind, setKind] = useState<ContactEntryKind>(
entry?.contact?.kind ?? mode.kind ?? (contact.balanceMinor < 0 ? "repay" : "lend"),
);
const [v, setV] = useState({
accountId: entry?.account?.id ?? entry?.toAccount?.id ?? "",
amount: entry ? formatMoneyInput(entry.amountMinor) : "",
date: entry?.date ?? todayIstanbul(),
description: entry?.description ?? "",
dueDate: contact.dueDate ?? "",
reminderDays: String(contact.reminderDays),
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
getTransactionFormOptionsAction().then((r) => {
if (cancelled || !r.ok) return;
setOptions(r.data);
setV((x) => (x.accountId ? x : { ...x, accountId: r.data.accounts[0]?.id ?? "" }));
});
return () => {
cancelled = true;
};
}, []);
const accounts = (() => {
const list = [...(options?.accounts ?? [])];
const current = entry?.account ?? entry?.toAccount;
if (current && !list.some((a) => a.id === current.id))
list.push({ ...current, type: "bank", icon: "wallet", color: "slate" });
return list;
})();
const createsDebt = kind === "lend" || kind === "borrow";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const payload = {
kind,
accountId: v.accountId,
amount: v.amount,
date: v.date,
description: v.description,
// Vade yalnızca borç veren/alan harekette güncellenir.
...(createsDebt ? { dueDate: v.dueDate, reminderDays: v.reminderDays } : {}),
};
const r = await saveContactEntryAction(contact.id, entry?.id ?? null, payload);
setBusy(false);
if (r.ok) {
toast.success(t.toast.entrySaved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
async function remove() {
if (!entry) return;
const r = await setTransactionDeletedAction(entry.id, true);
if (!r.ok) return void toast.error(r.error);
onClose();
toast.success(tr.transactions.toast.deleted, {
duration: 10_000,
action: {
label: tr.transactions.actions.restore,
onClick: async () => {
const x = await setTransactionDeletedAction(entry.id, false);
if (x.ok) toast.success(tr.transactions.toast.restored);
else toast.error(x.error);
},
},
});
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${entry ? t.editEntryTitle : t.entryTitle} · ${contact.name}`}
footer={
<>
{entry ? (
<Button variant="destructive" type="button" onClick={remove} className="sm:mr-auto">
<Trash2 />
{tr.transactions.actions.delete}
</Button>
) : null}
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="contact-entry-form" disabled={busy || !options}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
}
>
<form id="contact-entry-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs value={kind} onValueChange={(x) => setKind(x as ContactEntryKind)}>
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
{CONTACT_ENTRY_KINDS.map((k) => (
<TabsTrigger key={k} value={k}>
{t.kinds[k]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
<div className="grid grid-cols-[1fr_auto] gap-3">
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="ce-amount">{tr.transactions.fields.amount}</FieldLabel>
<MoneyInput
id="ce-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
aria-invalid={Boolean(errors.amount)}
autoFocus
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="ce-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="ce-date"
type="date"
value={v.date}
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
</div>
<Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="ce-account">
{isOutgoing(kind) ? f.fromAccount : f.toAccount}
</FieldLabel>
<AccountSelect
id="ce-account"
value={v.accountId}
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
accounts={accounts}
invalid={Boolean(errors.accountId)}
/>
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="ce-description">{tr.transactions.fields.description}</FieldLabel>
<Input
id="ce-description"
value={v.description}
onChange={(e) => setV((s) => ({ ...s, description: e.target.value }))}
/>
</Field>
{createsDebt ? (
<DueFields
dueDate={v.dueDate}
reminderDays={v.reminderDays}
onChange={(d, r) => setV((s) => ({ ...s, dueDate: d, reminderDays: r }))}
errors={errors}
/>
) : null}
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,155 @@
"use client";
import { ArrowLeft, Lock, Pencil, Phone, Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { PageHeader } from "@/components/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import type { ContactStatement } from "../service";
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
import { ContactBalance, DueBadge } from "./contacts-view";
const t = tr.contacts;
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
function Delta({ minor }: { minor: number }) {
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
minor > 0 ? "text-income" : "text-expense",
)}
>
{minor > 0 ? "+" : "−"}
{formatMoney(Math.abs(minor))}
</span>
);
}
/** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */
export function ContactStatementView({ data }: { data: ContactStatement }) {
const c = data.contact;
const [entry, setEntry] = useState<EntryMode | null>(null);
const [editing, setEditing] = useState(false);
const canWrite = data.canWrite && !c.archived;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/cari">
<ArrowLeft />
{t.title}
</Link>
</Button>
<PageHeader
title={c.name}
description={t.relations[c.relation]}
actions={
data.canWrite ? (
<>
<Button variant="outline" onClick={() => setEditing(true)}>
<Pencil />
<span className="hidden sm:inline">{tr.common.edit}</span>
</Button>
{canWrite ? (
<Button onClick={() => setEntry({ contact: c })}>
<Plus />
{t.newEntry}
</Button>
) : null}
</>
) : null
}
/>
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1 text-sm">
<DueBadge contact={c} />
{c.phone ? (
<a
href={`tel:${c.phone}`}
className="inline-flex items-center gap-1.5 hover:underline"
>
<Phone className="size-3.5" aria-hidden />
{c.phone}
</a>
) : null}
{c.isPrivate ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3.5" aria-hidden />
{t.privateBadge}
</span>
) : null}
{c.note ? <p className="text-muted-foreground">{c.note}</p> : null}
</div>
<ContactBalance minor={c.balanceMinor} className="text-lg" />
</CardContent>
</Card>
<h2 className="text-base font-medium">{t.statement}</h2>
{data.rows.length === 0 && c.openingBalanceMinor === 0 ? (
<Card>
<CardHeader>
<CardDescription>{t.emptyEntries}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">
{data.rows.map((r) => {
const content = (
<>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{tr.contacts.kinds[r.item.contact!.kind]}
</span>
<span className="block truncate text-xs text-muted-foreground">
{d(r.item.date)} · {r.item.account?.name ?? r.item.toAccount?.name ?? "—"}
{r.item.description ? ` · ${r.item.description}` : ""}
</span>
</span>
<span className="flex flex-col items-end">
<Delta minor={r.deltaMinor} />
<span
className="text-xs text-muted-foreground tabular-nums"
title={t.runningBalance}
>
{formatMoney(r.balanceMinor)}
</span>
</span>
</>
);
const cls = "flex w-full items-center gap-3 p-3 text-left";
return (
<li key={r.item.id}>
{canWrite ? (
<button
type="button"
className={cn(cls, "outline-none hover:bg-muted/50 focus-visible:bg-muted")}
onClick={() => setEntry({ contact: c, entry: r.item })}
>
{content}
</button>
) : (
<div className={cls}>{content}</div>
)}
</li>
);
})}
<li className="flex items-center gap-3 p-3 text-sm text-muted-foreground">
<span className="flex-1">
{t.opening} · {d(c.openingDate)}
</span>
<span className="tabular-nums">{formatMoney(c.openingBalanceMinor)}</span>
</li>
</ul>
)}
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
<ContactFormDialog open={editing} contact={c} onOpenChange={setEditing} />
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronDown,
EllipsisVertical,
Lock,
Pencil,
Plus,
Users,
} from "lucide-react";
import Link from "next/link";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatDate, formatMoney } from "@/shared/format";
import { setContactArchivedAction } from "../actions";
import type { ContactList, ContactView } from "../service";
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
const t = tr.contacts;
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
export function ContactBalance({ minor, className }: { minor: number; className?: string }) {
const label = minor > 0 ? t.balance.owesMe : minor < 0 ? t.balance.iOwe : t.balance.settled;
return (
<span className={cn("flex flex-col items-end", className)}>
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
minor > 0 && "text-income",
minor < 0 && "text-expense",
)}
>
{formatMoney(Math.abs(minor))}
</span>
</span>
);
}
export function DueBadge({ contact }: { contact: ContactView }) {
if (!contact.dueDate || contact.balanceMinor === 0) return null;
if (contact.due === "overdue")
return <Badge variant="destructive">{t.due.overdue(d(contact.dueDate))}</Badge>;
if (contact.due === "upcoming")
return <Badge variant="secondary">{t.due.upcoming(d(contact.dueDate))}</Badge>;
return <Badge variant="outline">{t.due.on(d(contact.dueDate))}</Badge>;
}
export function ContactsView({ data, canWrite }: { data: ContactList; canWrite: boolean }) {
const [form, setForm] = useState<{ contact: ContactView | null } | null>(null);
const [entry, setEntry] = useState<EntryMode | null>(null);
const [archivedOpen, setArchivedOpen] = useState(false);
const [, startTransition] = useTransition();
const active = data.contacts.filter((c) => !c.archived);
const archived = data.contacts.filter((c) => c.archived);
function archive(c: ContactView, value: boolean) {
startTransition(async () => {
const r = await setContactArchivedAction(c.id, value);
if (r.ok) toast.success(value ? t.toast.archived : t.toast.unarchived);
else toast.error(r.error);
});
}
const row = (c: ContactView) => (
<li key={c.id} className={cn("flex items-center gap-3 p-3", c.archived && "opacity-60")}>
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Users className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<Link href={`/cari/${c.id}`} className="truncate font-medium hover:underline">
{c.name}
</Link>
{c.isPrivate ? (
<Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label={t.privateBadge} />
) : null}
</span>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{t.relations[c.relation]}</span>
{c.lastActivity ? <span>· {t.lastActivity(d(c.lastActivity))}</span> : null}
<DueBadge contact={c} />
</span>
</div>
<ContactBalance minor={c.balanceMinor} />
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!c.archived ? (
<DropdownMenuItem onSelect={() => setEntry({ contact: c })}>
<Plus />
{t.newEntry}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => setForm({ contact: c })}>
<Pencil />
{tr.common.edit}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => archive(c, !c.archived)}>
{c.archived ? <ArchiveRestore /> : <Archive />}
{c.archived ? t.unarchive : t.archive}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
);
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
{(
[
[t.summary.owesMe, data.totals.owesMeMinor, "text-income"],
[t.summary.iOwe, data.totals.iOweMinor, "text-expense"],
[t.summary.net, data.totals.owesMeMinor - data.totals.iOweMinor, ""],
] as const
).map(([label, value, tone]) => (
<Card key={label} size="sm">
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className={cn("text-base tabular-nums", tone)}>
{formatMoney(value)}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
{canWrite ? (
<div className="flex justify-end">
<Button onClick={() => setForm({ contact: null })}>
<Plus />
{t.new}
</Button>
</div>
) : null}
{active.length === 0 ? (
<Card>
<CardHeader>
<CardDescription>{t.empty}</CardDescription>
</CardHeader>
</Card>
) : (
<ul className="divide-y rounded-xl border bg-card">{active.map(row)}</ul>
)}
{archived.length ? (
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
<ChevronDown className={cn("transition-transform", archivedOpen && "rotate-180")} />
{t.archivedSection(archived.length)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<ul className="divide-y rounded-xl border bg-card">{archived.map(row)}</ul>
</CollapsibleContent>
</Collapsible>
) : null}
<ContactFormDialog
open={Boolean(form)}
contact={form?.contact ?? null}
onOpenChange={(o) => !o && setForm(null)}
/>
<ContactEntryDialog mode={entry} onClose={() => setEntry(null)} />
</div>
);
}
@@ -0,0 +1,29 @@
import { HandCoins } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import { countDueContacts } from "../service";
/** Dashboard: vadesi geçmiş veya yaklaşan cari borç/alacak uyarısı (Faz 8 kararı: uygulama içi). */
export async function ContactsDueBanner({
userId,
householdId,
}: {
userId: string;
householdId: string;
}) {
const n = await countDueContacts(userId, householdId);
if (!n) return null;
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-budget/30 bg-budget/10 p-3 text-sm"
>
<HandCoins className="size-4 shrink-0 text-budget" aria-hidden />
<span className="flex-1">{tr.contacts.dueBanner(n)}</span>
<Button asChild size="sm" variant="outline">
<Link href="/cari">{tr.contacts.dueLink}</Link>
</Button>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import {
getTransaction,
listTransactions,
updateTransaction,
} from "@/features/transactions/service";
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
import { AccountModel } from "@/models/account";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import {
contactEntrySchema,
contactFormSchema,
type ContactEntryInput,
type ContactFormInput,
} from "@/shared/schemas/contact";
import { transactionFilterSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
countDueContacts,
createContact,
createContactEntry,
getContactStatement,
listContacts,
setContactArchived,
updateContact,
updateContactEntry,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const contactForm = (over: Partial<ContactFormInput> = {}) =>
contactFormSchema.parse({ name: "Test Kişi", openingDate: today, ...over });
const entry = (over: Partial<ContactEntryInput>) =>
contactEntrySchema.parse({ kind: "lend", amount: "1000", date: today, ...over });
async function balance(userId: string, accountId: string) {
return (await listAccounts(userId, cakir))!.accounts.find((a) => a.id === accountId)!
.balanceMinor!;
}
async function contactBalance(userId: string, id: string) {
return (await listContacts(userId, cakir))!.contacts.find((c) => c.id === id)!.balanceMinor;
}
const sums = async () =>
(await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
describe("cari defteri", () => {
let ahmet = "";
let lendId = "";
it("borç verme ve tahsilat: hesap bakiyeleri değişir, gelir/gider toplamlarına girmez", async () => {
const ziraat = await acc("Ziraat Maaş");
const cuzdan = await acc("Cüzdan");
const [zBefore, cBefore, sBefore] = [
await balance(ayse, ziraat),
await balance(ayse, cuzdan),
await sums(),
];
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Kemal Test", relation: "friend" }),
);
expect(c.ok).toBe(true);
ahmet = c.ok ? c.data.id : "";
const l = await createContactEntry(
ayse,
cakir,
ahmet,
entry({ accountId: ziraat, amount: "5.000" }),
);
expect(l.ok).toBe(true);
lendId = l.ok ? l.data.id : "";
await createContactEntry(
emre,
cakir,
ahmet,
entry({ kind: "collect", accountId: cuzdan, amount: "2.000" }),
);
expect(await balance(ayse, ziraat)).toBe(zBefore - 5_000_00);
expect(await balance(ayse, cuzdan)).toBe(cBefore + 2_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(3_000_00);
expect(await sums()).toEqual(sBefore);
const detail = (await getTransaction(ayse, cakir, lendId))!;
expect(detail).toMatchObject({
type: "transfer",
contact: { kind: "lend", name: "Kemal Test" },
});
expect(detail.toAccount).toBeNull();
});
it("borç alma ve geri ödeme; açılış bakiyesi; ekstre yürüyen bakiye", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Hasan Amca", relation: "family", openingBalance: "-1.000" }),
);
const hasan = c.ok ? c.data.id : "";
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "borrow", accountId: ziraat, amount: "10.000" }),
);
await createContactEntry(
ayse,
cakir,
hasan,
entry({ kind: "repay", accountId: ziraat, amount: "4.000" }),
);
expect(await contactBalance(ayse, hasan)).toBe(-7_000_00);
const s = (await getContactStatement(ayse, cakir, hasan))!;
expect(s.rows).toHaveLength(2);
expect(s.rows[0]!.balanceMinor).toBe(-7_000_00);
expect(s.rows[1]!.balanceMinor).toBe(-11_000_00);
expect(s.contact.balanceMinor).toBe(-7_000_00);
const list = (await listContacts(ayse, cakir))!;
expect(list.totals.iOweMinor).toBeGreaterThanOrEqual(7_000_00);
expect(list.totals.owesMeMinor).toBeGreaterThanOrEqual(3_000_00);
});
it("hareket düzenleme: tür değişince yön ve bakiyeler değişir; işlem formuyla düzenlenemez", async () => {
const ziraat = await acc("Ziraat Maaş");
const before = await balance(ayse, ziraat);
const u = await updateContactEntry(
ayse,
cakir,
lendId,
entry({ kind: "borrow", accountId: ziraat, amount: "5.000" }),
);
expect(u.ok).toBe(true);
expect(await balance(ayse, ziraat)).toBe(before + 10_000_00);
expect(await contactBalance(ayse, ahmet)).toBe(-7_000_00);
const d = (await getTransaction(ayse, cakir, lendId))!;
expect(d.account).toBeNull();
expect(d.toAccount?.id).toBe(ziraat);
const viaForm = await updateTransaction(ayse, cakir, lendId, {
type: "transfer",
amount: 1,
date: today,
accountId: ziraat,
toAccountId: await acc("Cüzdan"),
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
installments: 1,
attachmentIds: [],
});
expect(viaForm.ok).toBe(false);
});
it("vade uyarısı: yaklaşan, geçmiş, kapalı bakiye", async () => {
const c = await createContact(
ayse,
cakir,
contactForm({
name: "Vade Kişi",
openingBalance: "500",
dueDate: addDays(today, 2),
reminderDays: 3,
}),
);
const id = c.ok ? c.data.id : "";
const before = await countDueContacts(ayse, cakir);
let v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("upcoming");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "500", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("overdue");
await updateContact(
ayse,
cakir,
id,
contactForm({ name: "Vade Kişi", openingBalance: "0", dueDate: addDays(today, -1) }),
);
v = (await listContacts(ayse, cakir))!.contacts.find((x) => x.id === id)!;
expect(v.due).toBe("none");
expect(await countDueContacts(ayse, cakir)).toBe(before - 1);
});
it("aynı ad engellenir; arşivli cariye hareket eklenmez", async () => {
expect((await createContact(ayse, cakir, contactForm({ name: "kemal TEST" }))).ok).toBe(
false,
);
expect((await setContactArchived(ayse, cakir, ahmet, true)).ok).toBe(true);
expect(
(await createContactEntry(ayse, cakir, ahmet, entry({ accountId: await acc("Cüzdan") }))).ok,
).toBe(false);
expect((await setContactArchived(ayse, cakir, ahmet, false)).ok).toBe(true);
});
});
describe("yetki ve gizlilik", () => {
it("izleyici yazamaz; hane dışı göremez; başka hanenin hesabı kullanılamaz", async () => {
expect((await createContact(zeynep, cakir, contactForm({ name: "X" }))).ok).toBe(false);
expect(await listContacts(fatma, cakir)).toBeNull();
const c = await createContact(ayse, cakir, contactForm({ name: "Hesap Testi" }));
const id = c.ok ? c.data.id : "";
const otherHousehold = String(
(await AccountModel.findOne({ name: "Vakıfbank Emekli" }).lean())!._id,
);
expect(
(await createContactEntry(ayse, cakir, id, entry({ accountId: otherHousehold }))).ok,
).toBe(false);
});
it("yalnızca ben cari: başkası listede, ekstrede ve işlemlerde görmez; hesap ekstresinde ad gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const c = await createContact(
ayse,
cakir,
contactForm({ name: "Gizli Kişi", isPrivate: true }),
);
const id = c.ok ? c.data.id : "";
const e = await createContactEntry(
ayse,
cakir,
id,
entry({ accountId: ziraat, amount: "750" }),
);
const txId = e.ok ? e.data.id : "";
expect((await listContacts(emre, cakir))!.contacts.some((x) => x.id === id)).toBe(false);
expect(await getContactStatement(emre, cakir, id)).toBeNull();
expect(await getTransaction(emre, cakir, txId)).toBeNull();
expect((await createContactEntry(emre, cakir, id, entry({ accountId: ziraat }))).ok).toBe(
false,
);
const st = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = st.rows.find((r) => r.item.id === txId)!;
expect(row.restricted).toBe(true);
expect(row.item.contact!.name).not.toBe("Gizli Kişi");
// Ortak hesabın bakiyesi herkes için aynı.
expect(await balance(emre, ziraat)).toBe(await balance(ayse, ziraat));
});
});
+441
View File
@@ -0,0 +1,441 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import {
loadContext,
loadOwnTransaction,
toItem,
userNames,
type Ctx,
type LeanTx,
type TransactionListItem,
} from "@/features/transactions/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { ContactModel } from "@/models/contact";
import { TransactionModel } from "@/models/transaction";
import {
contactDelta,
dueState,
isOutgoing,
type ContactRelation,
type DueState,
} from "@/shared/contacts";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import type { ContactEntry, ContactForm } from "@/shared/schemas/contact";
import { compareTr, foldTr } from "@/shared/search";
/**
* Cari hesaplar (Bölüm 6.4). Okuma: viewer; yazma: member. Hareketler `transactions` içinde
* transferdir (`contactId`, `contactKind`): cariye çıkışta `accountId` dolu/`toAccountId` boş,
* cariden girişte tersi. Böylece hesap bakiyesi, ekstre ve mutabakat ek kural gerektirmez;
* gelir/gider raporlarına girmez.
*/
const t = tr.contacts;
type ContactDoc = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
name: string;
relation: ContactRelation;
phone: string;
note: string;
openingBalanceMinor: number;
openingDate: Date;
dueDate?: string | null;
reminderDays: number;
isPrivate: boolean;
ownerUserId?: Types.ObjectId | null;
archivedAt?: Date | null;
updatedAt: Date;
};
const visible = (c: ContactDoc, userId: string) => !c.isPrivate || String(c.ownerUserId) === userId;
/** Cari bazında hareket toplamı (+ çıkış, − giriş) ve son hareket tarihi. */
async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.ObjectId[]) {
const pipeline: PipelineStage[] = [
{
$match: {
householdId,
status: "posted",
deletedAt: null,
contactId: contactIds ? { $in: contactIds } : { $type: "objectId" },
},
},
{
$group: {
_id: "$contactId",
delta: {
$sum: {
$cond: [
{ $in: ["$contactKind", ["lend", "repay"]] },
"$amountMinor",
{ $multiply: ["$amountMinor", -1] },
],
},
},
last: { $max: "$date" },
},
},
];
const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number; last: Date }>(
pipeline,
);
return new Map(rows.map((r) => [String(r._id), r]));
}
export type ContactView = {
id: string;
name: string;
relation: ContactRelation;
phone: string;
note: string;
/** + bana borçlu, − ben borçluyum. */
balanceMinor: number;
openingBalanceMinor: number;
openingDate: string;
dueDate: string | null;
reminderDays: number;
due: DueState;
isPrivate: boolean;
archived: boolean;
lastActivity: string | null;
};
function toView(
c: ContactDoc,
totals: Map<string, { delta: number; last: Date }>,
today: string,
): ContactView {
const x = totals.get(String(c._id));
const balanceMinor = c.openingBalanceMinor + (x?.delta ?? 0);
return {
id: String(c._id),
name: c.name,
relation: c.relation,
phone: c.phone,
note: c.note,
balanceMinor,
openingBalanceMinor: c.openingBalanceMinor,
openingDate: toIstanbulYmd(c.openingDate),
dueDate: c.dueDate ?? null,
reminderDays: c.reminderDays,
due: dueState(balanceMinor, c.dueDate ?? null, c.reminderDays, today, addDays),
isPrivate: c.isPrivate,
archived: Boolean(c.archivedAt),
lastActivity: x?.last ? toIstanbulYmd(x.last) : null,
};
}
export type ContactList = {
contacts: ContactView[];
totals: { owesMeMinor: number; iOweMinor: number };
};
export async function listContacts(
userId: string,
householdId: string,
): Promise<ContactList | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const docs = (await ContactModel.find({ householdId: ctx.m.householdId }).lean<ContactDoc[]>())
.filter((c) => visible(c, userId))
.sort((a, b) => compareTr(a.name, b.name));
const totals = await contactTotals(
ctx.m.householdId,
docs.map((d) => d._id),
);
const today = todayIstanbul();
const contacts = docs.map((c) => toView(c, totals, today));
let owesMeMinor = 0;
let iOweMinor = 0;
for (const c of contacts) {
if (c.archived) continue;
if (c.balanceMinor > 0) owesMeMinor += c.balanceMinor;
else iOweMinor += -c.balanceMinor;
}
return { contacts, totals: { owesMeMinor, iOweMinor } };
}
async function loadContact(ctx: Ctx, contactId: string, userId: string) {
const id = toObjectId(contactId);
if (!id) return null;
const c = await ContactModel.findOne({
_id: id,
householdId: ctx.m.householdId,
}).lean<ContactDoc>();
return c && visible(c, userId) ? c : null;
}
async function nameTaken(householdId: Types.ObjectId, name: string, exceptId?: Types.ObjectId) {
const key = foldTr(name);
const others = await ContactModel.find(
{ householdId, archivedAt: null, ...(exceptId ? { _id: { $ne: exceptId } } : {}) },
{ name: 1 },
).lean();
return others.some((o) => foldTr(o.name) === key);
}
// --- Cari yazma ---
export async function createContact(
actorId: string,
householdId: string,
input: ContactForm,
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
if (await nameTaken(ctx.m.householdId, input.name))
return fail(t.errors.duplicate, { name: t.errors.duplicate });
const actor = new Types.ObjectId(actorId);
const doc = await ContactModel.create({
householdId: ctx.m.householdId,
name: input.name,
relation: input.relation,
phone: input.phone,
note: input.note,
openingBalanceMinor: input.openingBalance,
openingDate: istanbulDayStart(input.openingDate)!,
dueDate: input.dueDate,
reminderDays: input.reminderDays,
isPrivate: input.isPrivate,
ownerUserId: input.isPrivate ? actor : null,
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
export async function updateContact(
actorId: string,
householdId: string,
contactId: string,
input: ContactForm,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const c = await loadContact(ctx, contactId, actorId);
if (!c) return fail(tr.common.notFound);
if (await nameTaken(ctx.m.householdId, input.name, c._id))
return fail(t.errors.duplicate, { name: t.errors.duplicate });
// Gizlilik yalnızca sahibince (veya ortak cariyi gizlerken işlemi yapanca) belirlenir.
const owner = c.ownerUserId ?? (input.isPrivate ? new Types.ObjectId(actorId) : null);
await ContactModel.updateOne(
{ _id: c._id, householdId: ctx.m.householdId },
{
$set: {
name: input.name,
relation: input.relation,
phone: input.phone,
note: input.note,
openingBalanceMinor: input.openingBalance,
openingDate: istanbulDayStart(input.openingDate)!,
dueDate: input.dueDate,
reminderDays: input.reminderDays,
isPrivate: input.isPrivate,
ownerUserId: input.isPrivate ? owner : null,
updatedBy: new Types.ObjectId(actorId),
},
},
);
return ok(undefined);
}
export async function setContactArchived(
actorId: string,
householdId: string,
contactId: string,
archived: boolean,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const c = await loadContact(ctx, contactId, actorId);
if (!c) return fail(tr.common.notFound);
if (!archived && (await nameTaken(ctx.m.householdId, c.name, c._id)))
return fail(t.errors.duplicate);
await ContactModel.updateOne(
{ _id: c._id, householdId: ctx.m.householdId },
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: new Types.ObjectId(actorId) } },
);
await writeAudit({
action: archived ? "contact.archive" : "contact.unarchive",
actorId: new Types.ObjectId(actorId),
householdId: ctx.m.householdId,
targetType: "contact",
targetId: String(c._id),
diff: { name: c.name },
});
return ok(undefined);
}
// --- Cari hareketleri ---
function entryFields(ctx: Ctx, contact: ContactDoc, input: ContactEntry, keepAccount?: string) {
const info = ctx.accountMap.get(input.accountId);
if (!info || info.hidden || (info.archived && input.accountId !== keepAccount)) {
return fail(tr.transactions.validation.accountInvalid, {
accountId: tr.transactions.validation.accountInvalid,
});
}
const date = istanbulDayStart(input.date);
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
const out = isOutgoing(input.kind);
return ok({
type: "transfer" as const,
date,
accountId: out ? info.id : null,
toAccountId: out ? null : info.id,
contactId: contact._id,
contactKind: input.kind,
amountMinor: input.amount,
categoryId: null,
splits: [],
effectiveCategoryIds: [],
payeeName: "",
description: input.description,
searchText: foldTr(`${contact.name} ${input.description}`),
});
}
async function applyDue(contact: ContactDoc, input: ContactEntry, actor: Types.ObjectId) {
if (input.dueDate === undefined) return;
await ContactModel.updateOne(
{ _id: contact._id },
{
$set: {
dueDate: input.dueDate,
...(input.reminderDays !== undefined ? { reminderDays: input.reminderDays } : {}),
updatedBy: actor,
},
},
);
}
export async function createContactEntry(
actorId: string,
householdId: string,
contactId: string,
input: ContactEntry,
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const contact = await loadContact(ctx, contactId, actorId);
if (!contact) return fail(tr.common.notFound);
if (contact.archivedAt) return fail(t.errors.archived);
const fields = entryFields(ctx, contact, input);
if (!fields.ok) return fields;
const actor = new Types.ObjectId(actorId);
const doc = await TransactionModel.create({
...fields.data,
householdId: ctx.m.householdId,
status: "posted",
createdBy: actor,
updatedBy: actor,
});
await applyDue(contact, input, actor);
return ok({ id: String(doc._id) });
}
export async function updateContactEntry(
actorId: string,
householdId: string,
transactionId: string,
input: ContactEntry,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const tx = await loadOwnTransaction(ctx, transactionId);
if (!tx?.contactId) return fail(tr.common.notFound);
const contact = await loadContact(ctx, String(tx.contactId), actorId);
if (!contact) return fail(tr.common.notFound);
const keep = String(tx.accountId ?? tx.toAccountId ?? "");
const fields = entryFields(ctx, contact, input, keep);
if (!fields.ok) return fields;
const d = fields.data;
const balanceChanged =
d.amountMinor !== tx.amountMinor ||
d.date.getTime() !== new Date(tx.date).getTime() ||
String(d.accountId ?? "") !== String(tx.accountId ?? "") ||
String(d.toAccountId ?? "") !== String(tx.toAccountId ?? "");
const actor = new Types.ObjectId(actorId);
await TransactionModel.updateOne(
{ _id: tx._id, householdId: ctx.m.householdId },
{
$set: {
...d,
updatedBy: actor,
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
},
},
);
await applyDue(contact, input, actor);
return ok(undefined);
}
// --- Ekstre ---
export type ContactStatementRow = {
item: TransactionListItem;
deltaMinor: number;
balanceMinor: number;
};
export type ContactStatement = {
contact: ContactView;
/** Yeniden eskiye. */
rows: ContactStatementRow[];
canWrite: boolean;
};
export async function getContactStatement(
userId: string,
householdId: string,
contactId: string,
): Promise<ContactStatement | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const contact = await loadContact(ctx, contactId, userId);
if (!contact) return null;
const txs = (await TransactionModel.find({
householdId: ctx.m.householdId,
contactId: contact._id,
status: "posted",
deletedAt: null,
})
.sort({ date: 1, createdAt: 1 })
.limit(5000)
.lean()) as unknown as LeanTx[];
const names = await userNames(txs.map((x) => x.createdBy as Types.ObjectId));
let balance = contact.openingBalanceMinor;
const rows = txs.map((x): ContactStatementRow => {
const deltaMinor = contactDelta(x.contactKind!, x.amountMinor);
balance += deltaMinor;
const item = toItem(ctx, x, names);
// Başkasının gizli hesabıyla yapılan hareket: hesap adı gizlenir.
for (const a of [item.account, item.toAccount])
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
return { item, deltaMinor, balanceMinor: balance };
});
const totals = new Map([
[
String(contact._id),
{ delta: balance - contact.openingBalanceMinor, last: txs.at(-1)?.date ?? null },
],
]) as Map<string, { delta: number; last: Date }>;
return {
contact: toView(contact, totals, todayIstanbul()),
rows: rows.reverse(),
canWrite: hasHouseholdRole(ctx.m.role, "member"),
};
}
/** Dashboard: vadesi geçmiş veya yaklaşan (hatırlatma penceresindeki) cari sayısı. */
export async function countDueContacts(userId: string, householdId: string): Promise<number> {
const list = await listContacts(userId, householdId);
return list?.contacts.filter((c) => !c.archived && c.due !== "none").length ?? 0;
}
+1 -1
View File
@@ -196,7 +196,7 @@ describe("kural ve planlı kayıtlar", () => {
type: "expense",
amount: 777_00,
date: d.date,
accountId: d.account.id,
accountId: d.account!.id,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
+2 -1
View File
@@ -376,7 +376,8 @@ export async function confirmOccurrence(
ctx.m.householdId,
tx.payeeName ?? "",
tx.type,
tx.accountId,
// Planlı kayıtlar cari hareketi değildir; kaynak hesap her zaman dolu.
tx.accountId!,
tx.categoryId ?? null,
);
await TransactionModel.updateOne(
@@ -102,7 +102,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
type: x.type,
amount: formatMoneyInput(x.amountMinor),
date: mode.kind === "copy" ? todayIstanbul() : x.date,
accountId: x.account.id,
accountId: x.account?.id ?? "",
toAccountId: x.toAccount?.id ?? "",
categoryId: x.categoryId ?? "",
splits: x.splits.map((s) => ({
@@ -1,6 +1,16 @@
"use client";
import { ArrowLeftRight, CheckCheck, Copy, Pencil, Scale, Split, Trash2 } from "lucide-react";
import {
ArrowLeftRight,
Users,
CheckCheck,
Copy,
Pencil,
Scale,
Split,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
@@ -83,6 +93,13 @@ export function RowIcon({ item }: { item: TransactionListItem }) {
</span>
);
}
if (item.contact) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Users className="size-4.5" />
</span>
);
}
if (item.isAdjustment) {
return (
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
@@ -112,6 +129,7 @@ export function rowTitle(item: TransactionListItem) {
function baseTitle(item: TransactionListItem) {
if (item.isAdjustment) return t.adjustment;
if (item.contact) return item.contact.name;
if (item.payeeName) return item.payeeName;
if (item.description) return item.description;
if (item.type === "transfer") return t.types.transfer;
@@ -119,14 +137,18 @@ function baseTitle(item: TransactionListItem) {
}
export function rowSubtitle(item: TransactionListItem) {
if (item.contact) {
const account = item.account?.name ?? item.toAccount?.name ?? "—";
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
}
if (item.type === "transfer")
return t.list.transfer(item.account.name, item.toAccount?.name ?? "—");
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
const what = item.isAdjustment
? item.description
: item.splitCount
? t.list.split(item.splitCount)
: (item.category?.path ?? "—");
return `${what} · ${item.account.name}`;
return `${what} · ${item.account?.name ?? "—"}`;
}
function DetailDialog({
@@ -167,8 +189,15 @@ function DetailDialog({
<Trash2 />
{t.actions.delete}
</Button>
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */}
{detail.isAdjustment ? null : (
{/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; cari hareketi cari sayfasında. */}
{detail.contact ? (
<Button asChild>
<Link href={`/cari/${detail.contact.id}`}>
<Pencil />
{tr.contacts.openContact}
</Link>
</Button>
) : detail.isAdjustment ? null : (
<>
<Button variant="outline" onClick={() => onCopy(detail)}>
<Copy />
@@ -202,10 +231,20 @@ function DetailDialog({
<dl className="divide-y border-y">
{row(t.filters.type, <Badge variant="secondary">{t.types[detail.type]}</Badge>)}
{detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null}
{row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
detail.account.name,
)}
{detail.contact
? row(
tr.contacts.contact,
<Link href={`/cari/${detail.contact.id}`} className="hover:underline">
{detail.contact.name} · {tr.contacts.kinds[detail.contact.kind]}
</Link>,
)
: null}
{detail.account
? row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
detail.account.name,
)
: null}
{detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null}
{detail.category ? row(t.fields.category, detail.category.path) : null}
{detail.isAdjustment ? row(t.fields.category, t.adjustment) : null}
@@ -383,7 +383,7 @@ describe("filtreler", () => {
);
expect(byAccount!.total).toBeGreaterThan(0);
expect(
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan),
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan),
).toBe(true);
});
+52 -9
View File
@@ -13,10 +13,12 @@ import { writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { ContactModel } from "@/models/contact";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import type { ContactEntryKind } from "@/shared/contacts";
import { allowedAttachmentKinds } from "@/shared/attachments";
import type { IconKey, Swatch } from "@/shared/appearance";
import { installmentSchedule } from "@/shared/card-periods";
@@ -68,7 +70,7 @@ export async function loadContext(
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories] = await Promise.all([
const [accounts, categories, contacts] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
.lean(),
@@ -84,6 +86,9 @@ export async function loadContext(
ancestors: 1,
})
.lean(),
ContactModel.find({ householdId: m.householdId })
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
.lean(),
]);
const accountMap = new Map<string, AccountInfo>(
accounts.map((a) => [
@@ -126,18 +131,39 @@ export async function loadContext(
]),
);
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
const contactMap = new Map(
contacts.map((c) => [
String(c._id),
{
id: c._id,
name: c.name,
archived: Boolean(c.archivedAt),
hidden: Boolean(c.isPrivate) && String(c.ownerUserId) !== userId,
},
]),
);
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
return {
m,
accountMap,
categoryMap,
contactMap,
hiddenAccountIds,
hiddenContactIds,
flatCategories: flat,
};
}
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
export function baseFilter(ctx: Ctx): Record<string, unknown> {
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
if (ctx.hiddenAccountIds.length) {
f.accountId = { $nin: ctx.hiddenAccountIds };
f.toAccountId = { $nin: ctx.hiddenAccountIds };
}
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
return f;
}
@@ -221,7 +247,7 @@ function checkCategory(
}
type Existing = {
accountId: Types.ObjectId;
accountId?: Types.ObjectId | null;
toAccountId?: Types.ObjectId | null;
categoryId?: Types.ObjectId | null;
splits?: { categoryId: Types.ObjectId }[];
@@ -238,7 +264,7 @@ export function buildDoc(
const accountId = checkAccount(
ctx,
input.accountId,
existing ? String(existing.accountId) : null,
existing?.accountId ? String(existing.accountId) : null,
);
if (!accountId)
return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid });
@@ -445,6 +471,8 @@ export async function updateTransaction(
const current = await loadOwnTransaction(ctx, transactionId);
if (!current) return fail(tr.common.notFound);
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
if (current.contactId) return fail(t.contactEditHint);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as {
@@ -467,7 +495,7 @@ export async function updateTransaction(
d.type !== current.type ||
d.amountMinor !== current.amountMinor ||
d.date.getTime() !== new Date(current.date).getTime() ||
!d.accountId.equals(current.accountId) ||
String(d.accountId) !== String(current.accountId ?? "") ||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
try {
await mongoose.connection.transaction(async (session) => {
@@ -567,8 +595,11 @@ export type TransactionListItem = {
status: TransactionStatus;
date: string;
amountMinor: number;
account: { id: string; name: string };
/** Kaynak hesap; cariden girişte (borç alma, tahsilat) null. */
account: { id: string; name: string } | null;
toAccount: { id: string; name: string } | null;
/** Cari transferi: kişi ve hareket türü. */
contact: { id: string; name: string; kind: ContactEntryKind } | null;
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
splitCount: number;
payeeName: string;
@@ -629,7 +660,7 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
status: tx.status,
date: toIstanbulYmd(tx.date),
amountMinor: tx.amountMinor,
account: acc(tx.accountId)!,
account: acc(tx.accountId),
toAccount: acc(tx.toAccountId),
category: cat
? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color }
@@ -642,6 +673,14 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
attachmentCount: tx.attachmentCount ?? 0,
isAdjustment: Boolean(tx.isAdjustment),
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
contact:
tx.contactId && tx.contactKind
? {
id: String(tx.contactId),
name: ctx.contactMap.get(String(tx.contactId))?.name ?? "—",
kind: tx.contactKind,
}
: null,
installment: tx.installment?.planId
? {
planId: String(tx.installment.planId),
@@ -836,7 +875,11 @@ export async function exportTransactions(
} else {
out.push({
...common,
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
category: r.isAdjustment
? t.adjustment
: r.contactId && r.contactKind
? `${tr.contacts.contact}: ${ctx.contactMap.get(String(r.contactId))?.name ?? ""} (${tr.contacts.kinds[r.contactKind]})`
: categoryPath(r.categoryId),
note: "",
amountMinor: sign(r.type) * r.amountMinor,
});
+4 -1
View File
@@ -201,7 +201,10 @@ export async function getAccountStatement(
const effects = rowsAsc.map((tx) => {
const item = toItem(ctx, tx, names);
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
const restricted = counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
const hiddenContact = Boolean(item.contact && ctx.contactMap.get(item.contact.id)?.hidden);
if (hiddenContact && item.contact) item.contact.name = tr.transactions.list.hiddenContact;
const restricted =
hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
if (restricted) {
// Gizli karşı hesabın adı sızmasın.
for (const a of counterpart) {
+77
View File
@@ -298,6 +298,7 @@ export const tr = {
total: (n: number) => `${n} işlem`,
sums: { income: "Gelir", expense: "Gider", net: "Net" },
hiddenAccount: "Gizli hesap",
hiddenContact: "Gizli cari",
},
filters: {
title: "Filtreler",
@@ -361,6 +362,7 @@ export const tr = {
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
reconciled: "Mutabakatı yapıldı",
cancelInstallments: "Kalan taksitleri iptal et",
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git",
@@ -473,6 +475,80 @@ export const tr = {
upload: "Dosya yüklenemedi.",
},
},
contacts: {
title: "Cari hesaplar",
description:
"Kişilerle borç-alacak takibi. Cari hareketleri transferdir; gelir/gider raporlarına girmez.",
contact: "Cari",
openContact: "Cariyi aç",
new: "Yeni cari",
createTitle: "Yeni cari",
editTitle: "Cariyi düzenle",
relations: {
family: "Aile / akraba",
friend: "Arkadaş",
neighbor: "Komşu",
work: "İş",
other: "Diğer",
},
kinds: { lend: "Borç verdim", repay: "Borç ödedim", borrow: "Borç aldım", collect: "Tahsilat" },
kindHints: {
lend: "Hesabınızdan kişiye para verdiniz; size borçlanır.",
repay: "Kişiye olan borcunuzu hesabınızdan ödediniz.",
borrow: "Kişiden hesabınıza para aldınız; ona borçlanırsınız.",
collect: "Kişinin size olan borcunu hesabınıza tahsil ettiniz.",
},
fields: {
name: "Ad soyad",
relation: "Yakınlık",
phone: "Telefon",
note: "Not",
openingBalance: "Açılış bakiyesi (TL)",
openingHint:
"Uygulamadan önceki durum: size borçluysa pozitif, siz borçluysanız başına - koyarak girin.",
isPrivate: "Yalnızca ben (bu cariyi ve hareketlerini yalnızca ben görürüm)",
dueDate: "Vade tarihi",
dueHint: "Kalan bakiyenin ödenmesi beklenen tarih (isteğe bağlı).",
reminderDays: "Kaç gün önce uyarılsın",
account: "Hesap",
fromAccount: "Ödemenin çıktığı hesap",
toAccount: "Paranın girdiği hesap",
},
balance: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", settled: "Hesap kapalı" },
summary: { owesMe: "Bana borçlu", iOwe: "Ben borçluyum", net: "Net" },
newEntry: "Hareket ekle",
entryTitle: "Cari hareketi",
editEntryTitle: "Cari hareketini düzenle",
due: {
overdue: (d: string) => `Vade geçti · ${d}`,
upcoming: (d: string) => `Vade yaklaşıyor · ${d}`,
on: (d: string) => `Vade · ${d}`,
},
dueBanner: (n: number) => `${n} carinin vadesi geçti veya yaklaşıyor.`,
dueLink: "Carilere git",
statement: "Cari ekstresi",
opening: "Açılış bakiyesi",
runningBalance: "Bakiye",
lastActivity: (d: string) => `Son hareket ${d}`,
empty: "Henüz cari yok. Borç verdiğiniz veya aldığınız kişileri ekleyin.",
emptyEntries: "Henüz hareket yok.",
archive: "Arşivle",
unarchive: "Arşivden çıkar",
archivedSection: (n: number) => `Arşivlenmiş cariler (${n})`,
privateBadge: "Yalnızca ben",
readOnly: "İzleyici rolündesiniz; cari hareketi ekleyemezsiniz.",
toast: {
created: "Cari oluşturuldu.",
updated: "Cari güncellendi.",
entrySaved: "Cari hareketi kaydedildi.",
archived: "Cari arşivlendi.",
unarchived: "Cari arşivden çıkarıldı.",
},
errors: {
duplicate: "Bu adla bir cari zaten var.",
archived: "Arşivlenmiş cariye hareket eklenemez.",
},
},
card: {
title: "Kart özeti",
debt: "Toplam borç",
@@ -766,6 +842,7 @@ export const tr = {
validation: {
nameMin: "Ad en az 2 karakter olmalı",
nameMax: "Ad en fazla 80 karakter olabilir",
phone: "Geçerli bir telefon numarası girin",
usernameFormat: "Yalnızca küçük harf, rakam, nokta ve alt çizgi; 3–30 karakter",
email: "Geçerli bir e-posta adresi girin",
passwordMin: "Şifre en az 8 karakter olmalı",
+2
View File
@@ -28,6 +28,8 @@ export const AUDIT_ACTIONS = [
"transaction.delete",
"transaction.restore",
"recurring.delete",
"contact.archive",
"contact.unarchive",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+41
View File
@@ -0,0 +1,41 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { CONTACT_RELATIONS } from "@/shared/contacts";
/**
* Cari kişi (Bölüm 6.4). Hareketler `transactions` içinde transfer olarak tutulur (`contactId`);
* bakiye: açılış + çıkışlar − girişler (+ bana borçlu, − ben borçluyum).
* "Yalnızca ben" işaretli cari ve hareketleri yalnızca sahibine görünür.
*/
const contactSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
relation: { type: String, enum: CONTACT_RELATIONS, default: "other" },
phone: { type: String, default: "", maxlength: 30 },
note: { type: String, default: "", maxlength: 500 },
/** Uygulamaya başlamadan önceki bakiye (+ bana borçlu, − ben borçluyum). */
openingBalanceMinor: { type: Number, default: 0 },
openingDate: { type: Date, required: true },
/** Kalan bakiyenin vadesi ("YYYY-MM-DD") ve kaç gün önce uyarılacağı. */
dueDate: { type: String, default: null },
reminderDays: { type: Number, default: 3, min: 0, max: 60 },
isPrivate: { type: Boolean, default: false },
ownerUserId: { type: Schema.Types.ObjectId, default: null },
archivedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "contacts" },
);
contactSchema.index({ householdId: 1, archivedAt: 1, name: 1 });
contactSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type Contact = InferSchemaType<typeof contactSchema>;
export const ContactModel: Model<Contact> =
(models.Contact as Model<Contact> | undefined) ?? model<Contact>("Contact", contactSchema);
+6 -1
View File
@@ -1,4 +1,5 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
/**
@@ -21,7 +22,8 @@ const transactionSchema = new Schema(
status: { type: String, enum: TRANSACTION_STATUSES, default: "posted" },
/** İstanbul gününün başlangıç anı (UTC). */
date: { type: Date, required: true },
accountId: { type: Schema.Types.ObjectId, required: true },
/** Kaynak hesap. Cariden girişte (borç alma, tahsilat) null; hedef `toAccountId`. */
accountId: { type: Schema.Types.ObjectId, default: null },
toAccountId: { type: Schema.Types.ObjectId, default: null },
amountMinor: { type: Number, required: true, min: 1 },
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
@@ -53,7 +55,9 @@ const transactionSchema = new Schema(
default: null,
},
statementId: { type: Schema.Types.ObjectId, default: null },
/** Cari transferi: karşı taraf kişi (hesabın biri boş). */
contactId: { type: Schema.Types.ObjectId, default: null },
contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null },
/**
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
@@ -76,6 +80,7 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
transactionSchema.index({ householdId: 1, tags: 1 });
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
transactionSchema.index(
{ recurringRuleId: 1, occurrenceIndex: 1 },
+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;
}
}
}