feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 2e1f4d7929
commit 2e2e33233f
15 changed files with 1049 additions and 22 deletions
@@ -0,0 +1,20 @@
import { AttachmentModel } from "@/models/attachment";
import { ReconciliationModel } from "@/models/reconciliation";
import type { Migration } from "./types";
export const migration: Migration = {
name: "004-attachments-reconciliation",
description: "attachments ve reconciliations index'leri; işlemlerde mutabakat alanları",
async up(db) {
await AttachmentModel.createIndexes();
await ReconciliationModel.createIndexes();
const tx = db.collection("transactions");
// reconciledAt (hiç kullanılmadı) → hesap bazında reconciledAccountIds.
await tx.updateMany({ reconciledAt: { $exists: true } }, { $unset: { reconciledAt: "" } });
await tx.updateMany(
{ reconciledAccountIds: { $exists: false } },
{ $set: { reconciledAccountIds: [] } },
);
await tx.updateMany({ isAdjustment: { $exists: false } }, { $set: { isAdjustment: false } });
},
};
+2 -1
View File
@@ -3,10 +3,11 @@ import { connectMongoose } from "@/lib/db/mongoose";
import { migration as m001 } from "./001-initial-indexes";
import { migration as m002 } from "./002-accounts-categories";
import { migration as m003 } from "./003-transactions";
import { migration as m004 } from "./004-attachments-reconciliation";
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];
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004];
const COLLECTION = "migrations";
+13
View File
@@ -3,6 +3,7 @@ import { getAuth } from "@/lib/auth";
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env";
import { getStorage } from "@/lib/storage";
import { installDefaultCategories } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { HouseholdModel } from "@/models/household";
@@ -53,6 +54,10 @@ const DOMAIN_COLLECTIONS = [
"auditLogs",
"categories",
"accounts",
"transactions",
"payees",
"attachments",
"reconciliations",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -107,6 +112,14 @@ export async function runSeed({
};
if (reset) {
// Ek dosyaların diskteki kopyaları da silinsin (yetim dosya kalmasın).
const storage = getStorage();
for await (const a of db
.collection("attachments")
.find({}, { projection: { storageKey: 1, thumbKey: 1 } })) {
await storage.delete(String(a.storageKey));
if (a.thumbKey) await storage.delete(String(a.thumbKey));
}
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
await db.collection(name).deleteMany({});
}
+31
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import { transactionFormSchema } from "@/shared/schemas/transaction";
import {
createTransaction,
@@ -17,6 +18,7 @@ import {
type TransactionDetail,
type TransactionFormOptions,
} from "./service";
import { previewReconciliation, reconcileAccount, type ReconcilePreview } from "./statement";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
@@ -103,3 +105,32 @@ export async function searchPayeesAction(query: unknown): Promise<PayeeSuggestio
if (!ctx || !q.success) return [];
return searchPayees(ctx.user.id, ctx.household.id, q.data);
}
/** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */
export async function previewReconciliationAction(
accountId: unknown,
input: unknown,
): Promise<ActionResult<ReconcilePreview>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
if (!id.success) return fail(tr.common.notFound);
const parsed = reconcileSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
return previewReconciliation(ctx.user.id, ctx.household.id, id.data, parsed.data);
}
export async function reconcileAccountAction(
accountId: unknown,
input: unknown,
): Promise<ActionResult<{ adjustmentId: string | null; markedCount: number }>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const id = idSchema.safeParse(accountId);
if (!id.success) return fail(tr.common.notFound);
const parsed = reconcileSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await reconcileAccount(ctx.user.id, ctx.household.id, id.data, parsed.data);
if (result.ok) revalidate();
return result;
}
@@ -51,6 +51,7 @@ function form(over: Partial<TransactionForm>): TransactionForm {
payee: "",
description: "",
tags: [],
attachmentIds: [],
...over,
};
}
+121 -20
View File
@@ -1,5 +1,10 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
import {
listOwnerAttachments,
syncOwnerAttachments,
type AttachmentInfo,
} from "@/features/attachments/service";
import { resolveMembership } from "@/features/households/service";
import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree";
import { tr } from "@/i18n/tr";
@@ -12,6 +17,7 @@ import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import { allowedAttachmentKinds } from "@/shared/attachments";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates";
import type { CategoryKind } from "@/shared/schemas/category";
@@ -52,7 +58,12 @@ type CategoryInfo = {
ancestors: string[];
};
async function loadContext(userId: string, householdId: string, minRole: "viewer" | "member") {
/** Hane bağlamı (hesap/kategori haritaları, gizli hesaplar). İşlemler modülü içinde paylaşılır. */
export async function loadContext(
userId: string,
householdId: string,
minRole: "viewer" | "member",
) {
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
const [accounts, categories] = await Promise.all([
@@ -116,7 +127,7 @@ async function loadContext(userId: string, householdId: string, minRole: "viewer
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
}
type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
function baseFilter(ctx: Ctx): Record<string, unknown> {
@@ -316,15 +327,61 @@ export async function createTransaction(
d.categoryId,
);
const actor = new Types.ObjectId(actorId);
const doc = await TransactionModel.create({
...built.data,
try {
let id = "";
await mongoose.connection.transaction(async (session) => {
const [doc] = await TransactionModel.create(
[
{
...built.data,
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
id = String(doc!._id);
await attach(ctx, actorId, doc!._id, input, session);
});
return ok({ id });
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
}
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
class AttachmentError extends Error {
constructor(readonly result: ActionResult<never>) {
super(result.ok ? "" : result.error);
}
}
async function attach(
ctx: Ctx,
actorId: string,
transactionId: Types.ObjectId,
input: TransactionForm,
session: ClientSession,
) {
const synced = await syncOwnerAttachments({
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
actorId,
ownerType: "transaction",
ownerId: transactionId,
ids: input.attachmentIds,
allowedKinds: allowedAttachmentKinds(input.type),
session,
});
return ok({ id: String(doc._id) });
if (!synced.ok) throw new AttachmentError(synced);
await TransactionModel.updateOne(
{ _id: transactionId, householdId: ctx.m.householdId },
{ $set: { attachmentCount: synced.data } },
{ session },
);
}
async function loadOwnTransaction(
@@ -349,9 +406,17 @@ export async function updateTransaction(
if (!ctx) return fail(tr.common.unauthorized);
const current = await loadOwnTransaction(ctx, transactionId);
if (!current) return fail(tr.common.notFound);
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
const d = built.data as {
type: TransactionType;
date: Date;
amountMinor: number;
accountId: Types.ObjectId;
toAccountId: Types.ObjectId | null;
categoryId: Types.ObjectId | null;
};
const payeeId = await touchPayee(
ctx.m.householdId,
input.payee,
@@ -359,10 +424,33 @@ export async function updateTransaction(
d.accountId,
d.categoryId,
);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{ $set: { ...built.data, payeeId, updatedBy: new Types.ObjectId(actorId) } },
);
// Bakiyeyi etkileyen bir alan değiştiyse mutabakat işareti geçersizleşir.
const balanceChanged =
d.type !== current.type ||
d.amountMinor !== current.amountMinor ||
d.date.getTime() !== new Date(current.date).getTime() ||
!d.accountId.equals(current.accountId) ||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
try {
await mongoose.connection.transaction(async (session) => {
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{
$set: {
...built.data,
payeeId,
updatedBy: new Types.ObjectId(actorId),
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
},
},
{ session },
);
await attach(ctx, actorId, current._id, input, session);
});
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
return ok(undefined);
}
@@ -413,6 +501,10 @@ export type TransactionListItem = {
tags: string[];
createdBy: { id: string; name: string };
attachmentCount: number;
/** Mutabakat düzeltmesi (kategorisiz, düzenlenemez). */
isAdjustment: boolean;
/** Mutabakatı yapılmış hesaplar. */
reconciledAccountIds: string[];
};
export type TransactionDetail = TransactionListItem & {
@@ -429,9 +521,10 @@ export type TransactionDetail = TransactionListItem & {
updatedAt: string;
/** Düzenleme formu için ham kimlikler. */
categoryId: string | null;
attachments: AttachmentInfo[];
};
async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
export async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id));
const users = await getDb()
.collection("user")
@@ -440,13 +533,13 @@ async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")]));
}
type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
export type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
};
function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
const acc = (id: Types.ObjectId | null | undefined) => {
if (!id) return null;
const a = ctx.accountMap.get(String(id));
@@ -470,6 +563,8 @@ function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionLi
tags: tx.tags ?? [],
createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" },
attachmentCount: tx.attachmentCount ?? 0,
isAdjustment: Boolean(tx.isAdjustment),
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
};
}
@@ -571,8 +666,14 @@ export async function getTransaction(
if (!ctx) return null;
const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null;
if (!tx) return null;
const names = await userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]);
const [names, attachments] = await Promise.all([
userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]),
tx.attachmentCount
? listOwnerAttachments(ctx.m.householdId, "transaction", tx._id)
: Promise.resolve([]),
]);
return {
attachments,
...toItem(ctx, tx, names),
categoryId: tx.categoryId ? String(tx.categoryId) : null,
splits: (tx.splits ?? []).map((s) => {
@@ -650,7 +751,7 @@ export async function exportTransactions(
} else {
out.push({
...common,
category: categoryPath(r.categoryId),
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
note: "",
amountMinor: sign(r.type) * r.amountMinor,
});
@@ -0,0 +1,242 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import { AccountModel } from "@/models/account";
import { AuditLogModel } from "@/models/audit-log";
import { CategoryModel } from "@/models/category";
import { ReconciliationModel } from "@/models/reconciliation";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import type { TransactionForm } from "@/shared/schemas/transaction";
import { shiftMonth } from "@/shared/statement";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createTransaction,
getTransaction,
setTransactionDeleted,
updateTransaction,
} from "./service";
import {
currentMonth,
getAccountStatement,
previewReconciliation,
reconcileAccount,
} from "./statement";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
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 cat = (key: string) =>
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const input = (statementDate: string, statementBalance: string, acceptAdjustment = false) =>
reconcileSchema.parse({ statementDate, statementBalance, acceptAdjustment });
const tl = (minor: number) => (minor / 100).toFixed(2).replace(".", ",");
async function balanceOf(userId: string, accountId: string) {
const list = await listAccounts(userId, cakir);
return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
describe("hesap ekstresi", () => {
it("yürüyen bakiye tutarlı; ay sonu = sonraki ay başı; güncel bakiye hesap listesiyle aynı", async () => {
const ziraat = await acc("Ziraat Maaş");
const month = shiftMonth(currentMonth(), -1);
const s = (await getAccountStatement(ayse, cakir, ziraat, month))!;
expect(s.rows.length).toBeGreaterThan(0);
// Yeniden eskiye: ilk satırın bakiyesi ay sonu bakiyesi.
expect(s.rows[0]!.balanceMinor).toBe(s.closingMinor);
expect(s.closingMinor).toBe(s.openingMinor + s.inflowMinor - s.outflowMinor);
const asc = [...s.rows].reverse();
let running = s.openingMinor;
for (const r of asc) {
running += r.deltaMinor;
expect(r.balanceMinor).toBe(running);
}
const next = (await getAccountStatement(ayse, cakir, ziraat, currentMonth()))!;
expect(next.openingMinor).toBe(s.closingMinor);
expect(next.nextMonth).toBeNull();
expect(s.currentBalanceMinor).toBe(await balanceOf(ayse, ziraat));
});
it("gizli hesabın ekstresi yalnızca sahibine; hane dışına kapalı", async () => {
const pluxee = await acc("Ayşe — Pluxee");
expect(await getAccountStatement(ayse, cakir, pluxee, currentMonth())).not.toBeNull();
expect(await getAccountStatement(emre, cakir, pluxee, currentMonth())).toBeNull();
expect(await getAccountStatement(fatma, cakir, await acc("Cüzdan"), currentMonth())).toBeNull();
});
it("gizli hesaba transfer ekstrede görünür ama karşı hesap adı gizlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const pluxee = await acc("Ayşe — Pluxee");
const r = await createTransaction(ayse, cakir, {
type: "transfer",
amount: 123_45,
date: todayIstanbul(),
accountId: ziraat,
toAccountId: pluxee,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
} satisfies TransactionForm);
expect(r.ok).toBe(true);
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
const row = s.rows.find((x) => x.item.id === (r.ok ? r.data.id : ""))!;
expect(row.restricted).toBe(true);
expect(row.deltaMinor).toBe(-123_45);
expect(row.item.toAccount!.name).not.toContain("Pluxee");
expect(s.currentBalanceMinor).toBe(await balanceOf(emre, ziraat));
});
});
describe("mutabakat", () => {
it("bakiye tutuyorsa işlemler işaretlenir, düzeltme oluşmaz", async () => {
const cuzdan = await acc("Cüzdan");
const today = todayIstanbul();
const balance = await balanceOf(ayse, cuzdan);
const preview = await previewReconciliation(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(preview).toMatchObject({
ok: true,
data: { differenceMinor: 0, appBalanceMinor: balance },
});
const unreconciled = preview.ok ? preview.data.unreconciledCount : 0;
expect(unreconciled).toBeGreaterThan(0);
const r = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(r).toMatchObject({ ok: true, data: { adjustmentId: null, markedCount: unreconciled } });
const again = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance)));
expect(again).toMatchObject({ ok: true, data: { markedCount: 0 } });
const s = (await getAccountStatement(ayse, cakir, cuzdan, currentMonth()))!;
expect(s.rows.every((row) => row.reconciled)).toBe(true);
expect(s.lastReconciliation).toMatchObject({ date: today, differenceMinor: 0 });
});
it("fark varsa onay ister; onaylanınca düzeltme işlemi oluşur ve bakiye ekstreyle eşitlenir", async () => {
const ziraat = await acc("Ziraat Maaş");
const today = todayIstanbul();
const balance = await balanceOf(ayse, ziraat);
const target = balance - 250_00;
const refused = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target)));
expect(refused.ok).toBe(false);
expect(await ReconciliationModel.countDocuments({ accountId: ziraat })).toBe(0);
const r = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target), true));
expect(r.ok).toBe(true);
const adjustmentId = r.ok ? r.data.adjustmentId! : "";
const adj = (await TransactionModel.findById(adjustmentId).lean())!;
expect(adj).toMatchObject({ type: "expense", amountMinor: 250_00, isAdjustment: true });
expect(await balanceOf(ayse, ziraat)).toBe(target);
const audit = await AuditLogModel.findOne({
action: "account.reconcile",
targetId: ziraat,
}).lean();
expect(audit!.diff).toMatchObject({ differenceMinor: -250_00 });
// Düzeltme işlemi düzenlenemez, silinebilir.
const detail = (await getTransaction(ayse, cakir, adjustmentId))!;
expect(detail.isAdjustment).toBe(true);
const edit = await updateTransaction(ayse, cakir, adjustmentId, {
type: "expense",
amount: 1_00,
date: today,
accountId: ziraat,
toAccountId: null,
categoryId: await cat("market"),
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
});
expect(edit.ok).toBe(false);
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
});
it("kredi kartında borç pozitif girilir, uygulamada eksi bakiyeyle karşılaştırılır", async () => {
const kart = await acc("Garanti Bonus");
const balance = await balanceOf(ayse, kart);
expect(balance).toBeLessThan(0);
const preview = await previewReconciliation(
ayse,
cakir,
kart,
input(todayIstanbul(), tl(-balance)),
);
expect(preview).toMatchObject({
ok: true,
data: { statementBalanceMinor: balance, differenceMinor: 0 },
});
});
it("işaretli işlemin tutarı değişince işaret kalkar; yalnızca açıklama değişince kalır", async () => {
const cuzdan = await acc("Cüzdan");
const tx = await TransactionModel.findOne({
accountId: cuzdan,
type: "expense",
reconciledAccountIds: cuzdan,
splits: { $size: 0 },
deletedAt: null,
}).lean();
const d = (await getTransaction(ayse, cakir, String(tx!._id)))!;
const base: TransactionForm = {
type: "expense",
amount: d.amountMinor,
date: d.date,
accountId: cuzdan,
toAccountId: null,
categoryId: d.categoryId,
splits: [],
payee: d.payeeName,
description: "yeni açıklama",
tags: d.tags,
attachmentIds: [],
};
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
expect(
(await TransactionModel.findById(d.id).lean())!.reconciledAccountIds.map(String),
).toEqual([cuzdan]);
expect(
(await updateTransaction(ayse, cakir, d.id, { ...base, amount: d.amountMinor + 1 })).ok,
).toBe(true);
expect((await TransactionModel.findById(d.id).lean())!.reconciledAccountIds).toEqual([]);
});
it("yetki ve doğrulama: izleyici, gizli hesap, gelecek tarih", async () => {
const cuzdan = await acc("Cüzdan");
expect((await reconcileAccount(zeynep, cakir, cuzdan, input(todayIstanbul(), "0"))).ok).toBe(
false,
);
expect(
(await reconcileAccount(emre, cakir, await acc("Ayşe — Pluxee"), input(todayIstanbul(), "0")))
.ok,
).toBe(false);
expect((await previewReconciliation(ayse, cakir, cuzdan, input("2999-01-01", "0"))).ok).toBe(
false,
);
});
});
+455
View File
@@ -0,0 +1,455 @@
import "server-only";
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
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 { AccountModel } from "@/models/account";
import { ReconciliationModel } from "@/models/reconciliation";
import { TransactionModel } from "@/models/transaction";
import { isLiabilityType, type AccountType } from "@/shared/accounts";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
import { formatMoney } from "@/shared/format";
import { hasHouseholdRole } from "@/features/households/roles";
import type { ReconcileForm } from "@/shared/schemas/reconciliation";
import { foldTr } from "@/shared/search";
import { accountEffect, monthRange, shiftMonth, withRunningBalance } from "@/shared/statement";
import {
loadContext,
toItem,
userNames,
type Ctx,
type LeanTx,
type TransactionListItem,
} from "./service";
/**
* Hesap ekstresi (aylık, yürüyen bakiye) ve mutabakat (Bölüm 6.1).
* Ekstre yalnızca bakiyesini görebildiği hesaplar için açılır (gizli hesap → yalnızca sahibi).
* Başka birinin gizli hesabına yapılan transferler bakiyeyi etkilediği için ekstrede görünür,
* ama karşı hesap adı gizlenir ve ayrıntı açılmaz.
*/
const t = tr.statement;
const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_ROWS = 2000;
/** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */
function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
return {
householdId,
status: "posted" as const,
deletedAt: null,
$or: [{ accountId }, { toAccountId: accountId }],
};
}
/** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */
async function balanceBefore(
householdId: Types.ObjectId,
account: { _id: Types.ObjectId; openingBalanceMinor: number },
before: Date | null,
session?: ClientSession,
): Promise<number> {
const pipeline: PipelineStage[] = [
{
$match: {
...accountMatch(householdId, account._id),
...(before ? { date: { $lt: before } } : {}),
},
},
{
$group: {
_id: null,
total: {
$sum: {
$cond: [
{ $eq: ["$type", "transfer"] },
{
$cond: [
{ $eq: ["$accountId", account._id] },
{ $multiply: ["$amountMinor", -1] },
"$amountMinor",
],
},
{
$cond: [
{ $eq: ["$type", "income"] },
"$amountMinor",
{ $multiply: ["$amountMinor", -1] },
],
},
],
},
},
},
},
];
const agg = TransactionModel.aggregate<{ total: number }>(pipeline);
if (session) agg.session(session);
const [row] = await agg;
return account.openingBalanceMinor + (row?.total ?? 0);
}
type VisibleAccount = {
_id: Types.ObjectId;
name: string;
type: AccountType;
icon: string;
color: string;
openingBalanceMinor: number;
archivedAt?: Date | null;
};
async function loadVisibleAccount(
ctx: Ctx,
accountId: string,
session?: ClientSession,
): Promise<VisibleAccount | null> {
const id = toObjectId(accountId);
const info = id ? ctx.accountMap.get(String(id)) : undefined;
if (!id || !info || info.hidden) return null;
return AccountModel.findOne({ _id: id, householdId: ctx.m.householdId }, null, { session })
.select({ name: 1, type: 1, icon: 1, color: 1, openingBalanceMinor: 1, archivedAt: 1 })
.lean<VisibleAccount>();
}
// --- Ekstre ---
export type StatementRow = {
item: TransactionListItem;
deltaMinor: number;
balanceMinor: number;
reconciled: boolean;
/** Karşı hesap başkasının gizli hesabıysa ayrıntı açılmaz. */
restricted: boolean;
};
export type LastReconciliation = {
date: string;
statementBalanceMinor: number;
differenceMinor: number;
createdAt: string;
createdBy: string;
};
export type AccountStatement = {
account: {
id: string;
name: string;
type: AccountType;
icon: IconKey;
color: Swatch;
archived: boolean;
isLiability: boolean;
};
month: string;
prevMonth: string;
nextMonth: string | null;
openingMinor: number;
closingMinor: number;
inflowMinor: number;
outflowMinor: number;
currentBalanceMinor: number;
/** Yeniden eskiye. */
rows: StatementRow[];
truncated: boolean;
lastReconciliation: LastReconciliation | null;
canReconcile: boolean;
};
export function currentMonth(now = new Date()): string {
return todayIstanbul(now).slice(0, 7);
}
export async function getAccountStatement(
userId: string,
householdId: string,
accountId: string,
month: string,
): Promise<AccountStatement | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const account = await loadVisibleAccount(ctx, accountId);
if (!account) return null;
const range = monthRange(month);
const start = istanbulDayStart(range.start)!;
const end = istanbulDayStart(range.end)!;
const hh = ctx.m.householdId;
const [openingMinor, currentBalanceMinor, txs, last] = await Promise.all([
balanceBefore(hh, account, start),
balanceBefore(hh, account, null),
TransactionModel.find({ ...accountMatch(hh, account._id), date: { $gte: start, $lt: end } })
.sort({ date: 1, createdAt: 1 })
.limit(MAX_ROWS + 1)
.lean() as unknown as Promise<LeanTx[]>,
ReconciliationModel.findOne({ householdId: hh, accountId: account._id })
.sort({ statementDate: -1, createdAt: -1 })
.lean(),
]);
const truncated = txs.length > MAX_ROWS;
const rowsAsc = txs.slice(0, MAX_ROWS);
const names = await userNames([
...rowsAsc.map((r) => r.createdBy as Types.ObjectId),
...(last ? [last.createdBy] : []),
]);
const accountKey = String(account._id);
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);
if (restricted) {
// Gizli karşı hesabın adı sızmasın.
for (const a of counterpart) {
if (a && ctx.accountMap.get(a.id)?.hidden) a.name = tr.transactions.list.hiddenAccount;
}
}
return {
item,
restricted,
reconciled: item.reconciledAccountIds.includes(accountKey),
deltaMinor: accountEffect(
{
type: tx.type,
amountMinor: tx.amountMinor,
accountId: String(tx.accountId),
toAccountId: tx.toAccountId ? String(tx.toAccountId) : null,
},
accountKey,
),
};
});
const running = withRunningBalance(openingMinor, effects);
const inflowMinor = effects.filter((e) => e.deltaMinor > 0).reduce((a, e) => a + e.deltaMinor, 0);
const outflowMinor = effects
.filter((e) => e.deltaMinor < 0)
.reduce((a, e) => a - e.deltaMinor, 0);
const now = currentMonth();
return {
account: {
id: accountKey,
name: account.name,
type: account.type,
icon: account.icon as IconKey,
color: account.color as Swatch,
archived: Boolean(account.archivedAt),
isLiability: isLiabilityType(account.type),
},
month,
prevMonth: shiftMonth(month, -1),
nextMonth: month < now ? shiftMonth(month, 1) : null,
openingMinor,
closingMinor: running.at(-1)?.balanceMinor ?? openingMinor,
inflowMinor,
outflowMinor,
currentBalanceMinor,
rows: running.reverse(),
truncated,
lastReconciliation: last
? {
date: toIstanbulYmd(last.statementDate),
statementBalanceMinor: last.statementBalanceMinor,
differenceMinor: last.differenceMinor,
createdAt: new Date(last.createdAt).toISOString(),
createdBy: names.get(String(last.createdBy)) ?? "—",
}
: null,
canReconcile: hasHouseholdRole(ctx.m.role, "member") && !account.archivedAt,
};
}
// --- Mutabakat ---
export type ReconcilePreview = {
statementDate: string;
/** Uygulamadaki işaretle (kredi kartında eksi). */
statementBalanceMinor: number;
appBalanceMinor: number;
/** ekstre − uygulama: + ise uygulamada eksik gelir, − ise eksik gider. */
differenceMinor: number;
/** O tarihe kadar henüz işaretlenmemiş işlem sayısı. */
unreconciledCount: number;
};
function validateDate(ymd: string): Date | null {
const start = istanbulDayStart(ymd);
if (!start || ymd > todayIstanbul()) return null;
return start;
}
async function computePreview(
ctx: Ctx,
account: VisibleAccount,
input: ReconcileForm,
session?: ClientSession,
): Promise<ActionResult<ReconcilePreview & { endExclusive: Date; dayStart: Date }>> {
const dayStart = validateDate(input.statementDate);
if (!dayStart) return fail(t.errors.date, { statementDate: t.errors.date });
const endExclusive = new Date(dayStart.getTime() + DAY_MS);
// Kredi kartında kullanıcı borcu pozitif girer; uygulamada borç eksi bakiyedir.
const statementBalanceMinor = isLiabilityType(account.type)
? -input.statementBalance
: input.statementBalance;
const appBalanceMinor = await balanceBefore(ctx.m.householdId, account, endExclusive, session);
const unreconciledCount = await TransactionModel.countDocuments(
{
...accountMatch(ctx.m.householdId, account._id),
date: { $lt: endExclusive },
reconciledAccountIds: { $ne: account._id },
},
{ session },
);
return ok({
statementDate: input.statementDate,
statementBalanceMinor,
appBalanceMinor,
differenceMinor: statementBalanceMinor - appBalanceMinor,
unreconciledCount,
endExclusive,
dayStart,
});
}
export async function previewReconciliation(
userId: string,
householdId: string,
accountId: string,
input: ReconcileForm,
): Promise<ActionResult<ReconcilePreview>> {
const ctx = await loadContext(userId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const account = await loadVisibleAccount(ctx, accountId);
if (!account) return fail(tr.common.notFound);
const result = await computePreview(ctx, account, input);
if (!result.ok) return result;
const { endExclusive: _e, dayStart: _d, ...preview } = result.data;
return ok(preview);
}
class ReconcileAbort extends Error {
constructor(readonly result: ActionResult<never>) {
super("abort");
}
}
/**
* Mutabakatı uygular (tek transaction): fark varsa (ve onaylandıysa) düzeltme işlemi oluşturur,
* ekstre tarihine kadarki işlemleri bu hesap için işaretler, kaydı ve denetim kaydını yazar.
*/
export async function reconcileAccount(
actorId: string,
householdId: string,
accountId: string,
input: ReconcileForm,
): Promise<
ActionResult<{ adjustmentId: string | null; markedCount: number; differenceMinor: number }>
> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const pre = await loadVisibleAccount(ctx, accountId);
if (!pre) return fail(tr.common.notFound);
if (pre.archivedAt) return fail(t.errors.archived);
const actor = new Types.ObjectId(actorId);
const hh = ctx.m.householdId;
try {
let out = { adjustmentId: null as string | null, markedCount: 0, differenceMinor: 0 };
await mongoose.connection.transaction(async (session) => {
const account = (await loadVisibleAccount(ctx, accountId, session))!;
const preview = await computePreview(ctx, account, input, session);
if (!preview.ok) throw new ReconcileAbort(preview);
const p = preview.data;
if (p.differenceMinor !== 0 && !input.acceptAdjustment) {
throw new ReconcileAbort(fail(t.errors.needsAdjustment));
}
let adjustmentId: Types.ObjectId | null = null;
if (p.differenceMinor !== 0) {
const description = t.adjustmentDescription(formatMoney(p.statementBalanceMinor));
const [adj] = await TransactionModel.create(
[
{
householdId: hh,
type: p.differenceMinor > 0 ? "income" : "expense",
status: "posted",
date: p.dayStart,
accountId: account._id,
amountMinor: Math.abs(p.differenceMinor),
categoryId: null,
splits: [],
effectiveCategoryIds: [],
payeeName: "",
description,
searchText: foldTr(`${tr.transactions.adjustment} ${description}`),
isAdjustment: true,
reconciledAccountIds: [account._id],
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
adjustmentId = adj!._id;
}
const marked = await TransactionModel.updateMany(
{
...accountMatch(hh, account._id),
date: { $lt: p.endExclusive },
reconciledAccountIds: { $ne: account._id },
},
{ $addToSet: { reconciledAccountIds: account._id } },
{ session },
);
await ReconciliationModel.create(
[
{
householdId: hh,
accountId: account._id,
statementDate: p.dayStart,
statementBalanceMinor: p.statementBalanceMinor,
appBalanceMinor: p.appBalanceMinor,
differenceMinor: p.differenceMinor,
adjustmentTransactionId: adjustmentId,
markedCount: marked.modifiedCount,
createdBy: actor,
},
],
{ session },
);
await writeAudit(
{
action: "account.reconcile",
actorId: actor,
householdId: hh,
targetType: "account",
targetId: String(account._id),
diff: {
statementDate: p.statementDate,
statementBalanceMinor: p.statementBalanceMinor,
appBalanceMinor: p.appBalanceMinor,
differenceMinor: p.differenceMinor,
adjustmentTransactionId: adjustmentId ? String(adjustmentId) : null,
markedCount: marked.modifiedCount,
},
},
session,
);
out = {
adjustmentId: adjustmentId ? String(adjustmentId) : null,
markedCount: marked.modifiedCount,
differenceMinor: p.differenceMinor,
};
});
return ok(out);
} catch (error) {
if (error instanceof ReconcileAbort) return error.result;
throw error;
}
}
+1
View File
@@ -21,6 +21,7 @@ export const AUDIT_ACTIONS = [
"account.update",
"account.archive",
"account.unarchive",
"account.reconcile",
"category.archive",
"category.unarchive",
"category.move",
+30
View File
@@ -0,0 +1,30 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Mutabakat kaydı (Bölüm 6.1): kullanıcı ekstre bakiyesini girdi, uygulama bakiyesiyle
* karşılaştırıldı, fark varsa düzeltme işlemi oluşturuldu ve o tarihe kadarki işlemler işaretlendi.
*/
const reconciliationSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
accountId: { type: Schema.Types.ObjectId, required: true },
/** İstanbul gününün başlangıç anı (UTC); o günün sonuna kadarki işlemler dahil. */
statementDate: { type: Date, required: true },
/** Uygulamadaki işaretle (kredi kartında eksi = borç). */
statementBalanceMinor: { type: Number, required: true },
appBalanceMinor: { type: Number, required: true },
differenceMinor: { type: Number, required: true },
adjustmentTransactionId: { type: Schema.Types.ObjectId, default: null },
markedCount: { type: Number, default: 0 },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: { createdAt: true, updatedAt: false }, collection: "reconciliations" },
);
reconciliationSchema.index({ householdId: 1, accountId: 1, statementDate: -1, createdAt: -1 });
export type Reconciliation = InferSchemaType<typeof reconciliationSchema>;
export const ReconciliationModel: Model<Reconciliation> =
(models.Reconciliation as Model<Reconciliation> | undefined) ??
model<Reconciliation>("Reconciliation", reconciliationSchema);
+8 -1
View File
@@ -35,6 +35,7 @@ const transactionSchema = new Schema(
/** Arama için normalize metin (açıklama + satıcı + kalem notları). */
searchText: { type: String, default: "" },
tags: { type: [String], default: [] },
/** Bağlı (silinmemiş) ek dosya sayısı; liste ikonu için (asıl kayıt attachments'ta). */
attachmentCount: { type: Number, default: 0 },
// İleri fazlar: planlı işlem kuralı (7), taksit (7), kart dönemi (7), cari (8).
recurringRuleId: { type: Schema.Types.ObjectId, default: null },
@@ -47,7 +48,13 @@ const transactionSchema = new Schema(
},
statementId: { type: Schema.Types.ObjectId, default: null },
contactId: { type: Schema.Types.ObjectId, default: null },
reconciledAt: { type: Date, 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.
*/
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
isAdjustment: { type: Boolean, default: false },
deletedAt: { type: Date, default: null },
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
seedRef: { type: String, default: null },
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
const v = tr.validation;
/**
* Mutabakat formu (web + mobil). `statementBalance` ekstredeki bakiye; kredi kartında
* ekstredeki BORÇ tutarı pozitif girilir (sunucu uygulamadaki eksi bakiyeye çevirir).
*/
export const reconcileSchema = z.object({
statementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date),
statementBalance: z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
}),
/** Fark varsa düzeltme işlemi oluşturulmasını onaylar. */
acceptAdjustment: z.boolean().default(false),
});
export type ReconcileInput = z.input<typeof reconcileSchema>;
export type ReconcileForm = z.output<typeof reconcileSchema>;
+6
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
const v = tr.validation;
@@ -52,6 +53,11 @@ export const transactionFormSchema = z
.union([z.string(), z.array(z.string())])
.default("")
.transform((x) => normalizeTags(x)),
/** Bağlı ek dosyalar (sıralı). Yeni ekler önce /api/files ile yüklenir. */
attachmentIds: z
.array(z.string().regex(/^[a-f0-9]{24}$/i))
.max(MAX_ATTACHMENTS_PER_RECORD, tr.attachments.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD))
.default([]),
})
.superRefine((x, ctx) => {
if (x.type === "transfer") {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
accountEffect,
monthRange,
MONTH_PATTERN,
shiftMonth,
withRunningBalance,
} from "./statement";
describe("ay yardımcıları", () => {
it("monthRange ve shiftMonth yıl sınırını aşar", () => {
expect(monthRange("2026-12")).toEqual({ start: "2026-12-01", end: "2027-01-01" });
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
expect(shiftMonth("2026-11", 14)).toBe("2028-01");
expect(MONTH_PATTERN.test("2026-13")).toBe(false);
expect(MONTH_PATTERN.test("2026-09")).toBe(true);
});
});
describe("accountEffect", () => {
const a = "A";
it("gelir +, gider −, transfer kaynak − / hedef +, ilgisiz 0", () => {
expect(accountEffect({ type: "income", amountMinor: 500, accountId: a }, a)).toBe(500);
expect(accountEffect({ type: "expense", amountMinor: 500, accountId: a }, a)).toBe(-500);
const tr = { type: "transfer" as const, amountMinor: 300, accountId: a, toAccountId: "B" };
expect(accountEffect(tr, a)).toBe(-300);
expect(accountEffect(tr, "B")).toBe(300);
expect(accountEffect(tr, "C")).toBe(0);
expect(accountEffect({ type: "expense", amountMinor: 1, accountId: "B" }, a)).toBe(0);
});
});
describe("withRunningBalance", () => {
it("açılıştan başlayarak biriktirir", () => {
expect(
withRunningBalance(1000, [{ deltaMinor: -200 }, { deltaMinor: 50 }]).map(
(r) => r.balanceMinor,
),
).toEqual([800, 850]);
expect(withRunningBalance(5, [])).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { TransactionType } from "./transactions";
/** Hesap ekstresi yardımcıları (Bölüm 6.1). Saf fonksiyonlar; sunucu ve testler kullanır. */
export const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
/** "2026-11" → { start: "2026-11-01", end: "2026-12-01" } (end hariç). */
export function monthRange(ym: string): { start: string; end: string } {
return { start: `${ym}-01`, end: `${shiftMonth(ym, 1)}-01` };
}
/** "2026-01", -1 → "2025-12". */
export function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split("-").map(Number) as [number, number];
const index = y * 12 + (m - 1) + delta;
return `${Math.floor(index / 12)}-${String((index % 12) + 1).padStart(2, "0")}`;
}
/** Bir işlemin belirli bir hesaba etkisi (kuruş): gelir +, gider −, transfer kaynak − / hedef +. */
export function accountEffect(
tx: {
type: TransactionType;
amountMinor: number;
accountId: string;
toAccountId?: string | null;
},
accountId: string,
): number {
if (tx.type === "transfer") {
if (tx.accountId === accountId) return -tx.amountMinor;
if (tx.toAccountId === accountId) return tx.amountMinor;
return 0;
}
if (tx.accountId !== accountId) return 0;
return tx.type === "income" ? tx.amountMinor : -tx.amountMinor;
}
/** Kronolojik sıralı etkilere yürüyen bakiye ekler. */
export function withRunningBalance<T extends { deltaMinor: number }>(
openingMinor: number,
rows: readonly T[],
): (T & { balanceMinor: number })[] {
let balance = openingMinor;
return rows.map((r) => {
balance += r.deltaMinor;
return { ...r, balanceMinor: balance };
});
}