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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
parent dfb83d6fd4
commit 5d370a062e
18 changed files with 1060 additions and 26 deletions
+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;
}