9781cd9ed2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
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" },
|
||
// Aynî borç hareketleri cari TL bakiyesine girmez.
|
||
contactKind: { $ne: null },
|
||
},
|
||
},
|
||
{
|
||
$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,
|
||
contactKind: { $ne: null },
|
||
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;
|
||
}
|