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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const AUDIT_ACTIONS = [
|
||||
"account.update",
|
||||
"account.archive",
|
||||
"account.unarchive",
|
||||
"account.reconcile",
|
||||
"category.archive",
|
||||
"category.unarchive",
|
||||
"category.move",
|
||||
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
@@ -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") {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user