From 2e2e33233f541fe4b7469e9de388b7ab84cbc106 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 07:55:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(transactions):=20ek=20ba=C4=9Flama,=20hesa?= =?UTF-8?q?p=20ekstresi=20ve=20mutabakat=20servisi,=20004=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../004-attachments-reconciliation.ts | 20 + scripts/migrations/index.ts | 3 +- scripts/seed/run.ts | 13 + src/features/transactions/actions.ts | 31 ++ src/features/transactions/service.int.test.ts | 1 + src/features/transactions/service.ts | 141 +++++- .../transactions/statement.int.test.ts | 242 ++++++++++ src/features/transactions/statement.ts | 455 ++++++++++++++++++ src/models/audit-log.ts | 1 + src/models/reconciliation.ts | 30 ++ src/models/transaction.ts | 9 +- src/shared/schemas/reconciliation.ts | 29 ++ src/shared/schemas/transaction.ts | 6 + src/shared/statement.test.ts | 42 ++ src/shared/statement.ts | 48 ++ 15 files changed, 1049 insertions(+), 22 deletions(-) create mode 100644 scripts/migrations/004-attachments-reconciliation.ts create mode 100644 src/features/transactions/statement.int.test.ts create mode 100644 src/features/transactions/statement.ts create mode 100644 src/models/reconciliation.ts create mode 100644 src/shared/schemas/reconciliation.ts create mode 100644 src/shared/statement.test.ts create mode 100644 src/shared/statement.ts diff --git a/scripts/migrations/004-attachments-reconciliation.ts b/scripts/migrations/004-attachments-reconciliation.ts new file mode 100644 index 0000000..11ab024 --- /dev/null +++ b/scripts/migrations/004-attachments-reconciliation.ts @@ -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 } }); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 78b0ab2..97c833f 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -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"; diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index bc84c64..1bb65f7 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -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["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({}); } diff --git a/src/features/transactions/actions.ts b/src/features/transactions/actions.ts index ffa62d8..ae95cbc 100644 --- a/src/features/transactions/actions.ts +++ b/src/features/transactions/actions.ts @@ -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> { + 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> { + 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; +} diff --git a/src/features/transactions/service.int.test.ts b/src/features/transactions/service.int.test.ts index dd84b05..18667f9 100644 --- a/src/features/transactions/service.int.test.ts +++ b/src/features/transactions/service.int.test.ts @@ -51,6 +51,7 @@ function form(over: Partial): TransactionForm { payee: "", description: "", tags: [], + attachmentIds: [], ...over, }; } diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 4869cd7..1860941 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -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>>; +export type Ctx = NonNullable>>; /** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */ function baseFilter(ctx: Ctx): Record { @@ -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) { + 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> { +export async function userNames(ids: Types.ObjectId[]): Promise> { 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> { return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")])); } -type LeanTx = Awaited>[number] & { +export type LeanTx = Awaited>[number] & { _id: Types.ObjectId; createdAt: Date; updatedAt: Date; }; -function toItem(ctx: Ctx, tx: LeanTx, names: Map): TransactionListItem { +export function toItem(ctx: Ctx, tx: LeanTx, names: Map): 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): 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, }); diff --git a/src/features/transactions/statement.int.test.ts b/src/features/transactions/statement.int.test.ts new file mode 100644 index 0000000..be6aa7a --- /dev/null +++ b/src/features/transactions/statement.int.test.ts @@ -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>; +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, + ); + }); +}); diff --git a/src/features/transactions/statement.ts b/src/features/transactions/statement.ts new file mode 100644 index 0000000..1922505 --- /dev/null +++ b/src/features/transactions/statement.ts @@ -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 { + 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 { + 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(); +} + +// --- 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 { + 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, + 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> { + 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> { + 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) { + 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; + } +} diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index c52b775..ef87e05 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -21,6 +21,7 @@ export const AUDIT_ACTIONS = [ "account.update", "account.archive", "account.unarchive", + "account.reconcile", "category.archive", "category.unarchive", "category.move", diff --git a/src/models/reconciliation.ts b/src/models/reconciliation.ts new file mode 100644 index 0000000..44cae62 --- /dev/null +++ b/src/models/reconciliation.ts @@ -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; + +export const ReconciliationModel: Model = + (models.Reconciliation as Model | undefined) ?? + model("Reconciliation", reconciliationSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index ac1e83c..b66e115 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -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 }, diff --git a/src/shared/schemas/reconciliation.ts b/src/shared/schemas/reconciliation.ts new file mode 100644 index 0000000..2a53c79 --- /dev/null +++ b/src/shared/schemas/reconciliation.ts @@ -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; +export type ReconcileForm = z.output; diff --git a/src/shared/schemas/transaction.ts b/src/shared/schemas/transaction.ts index e160eca..2a4012c 100644 --- a/src/shared/schemas/transaction.ts +++ b/src/shared/schemas/transaction.ts @@ -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") { diff --git a/src/shared/statement.test.ts b/src/shared/statement.test.ts new file mode 100644 index 0000000..c3951cd --- /dev/null +++ b/src/shared/statement.test.ts @@ -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([]); + }); +}); diff --git a/src/shared/statement.ts b/src/shared/statement.ts new file mode 100644 index 0000000..9bc960f --- /dev/null +++ b/src/shared/statement.ts @@ -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( + openingMinor: number, + rows: readonly T[], +): (T & { balanceMinor: number })[] { + let balance = openingMinor; + return rows.map((r) => { + balance += r.deltaMinor; + return { ...r, balanceMinor: balance }; + }); +}