From 1208e2d1eb9e2318aee2e9cee260571c1c285df7 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 03:23:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(transactions):=20i=C5=9Flem=20servisi,=20b?= =?UTF-8?q?akiye=20hesaplama,=20sat=C4=B1c=C4=B1=20kurallar=C4=B1=20ve=20i?= =?UTF-8?q?zolasyon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../accounts/components/accounts-view.tsx | 8 +- src/features/accounts/service.int.test.ts | 9 +- src/features/accounts/service.ts | 20 +- src/features/transactions/actions.ts | 105 +++ src/features/transactions/service.int.test.ts | 434 +++++++++++ src/features/transactions/service.ts | 727 ++++++++++++++++++ src/models/audit-log.ts | 2 + src/models/payee.ts | 25 + src/models/transaction.ts | 75 ++ 9 files changed, 1394 insertions(+), 11 deletions(-) create mode 100644 src/features/transactions/actions.ts create mode 100644 src/features/transactions/service.int.test.ts create mode 100644 src/features/transactions/service.ts create mode 100644 src/models/payee.ts create mode 100644 src/models/transaction.ts diff --git a/src/features/accounts/components/accounts-view.tsx b/src/features/accounts/components/accounts-view.tsx index eec7c68..c8a994a 100644 --- a/src/features/accounts/components/accounts-view.tsx +++ b/src/features/accounts/components/accounts-view.tsx @@ -11,6 +11,7 @@ import { Pencil, Plus, } from "lucide-react"; +import Link from "next/link"; import { useState, useTransition } from "react"; import { toast } from "sonner"; import { IconBadge } from "@/components/appearance/swatch"; @@ -109,7 +110,12 @@ export function AccountsView({
  • -

    {a.name}

    + + {a.name} +
    {a.type === "meal_card" && a.mealCardProvider ? ( {t.providers[a.mealCardProvider]} diff --git a/src/features/accounts/service.int.test.ts b/src/features/accounts/service.int.test.ts index 1db1033..84ecd95 100644 --- a/src/features/accounts/service.int.test.ts +++ b/src/features/accounts/service.int.test.ts @@ -89,7 +89,9 @@ describe("“yalnızca ben” hesabı", () => { (await listAccounts(ids.user(user), cakir))!.accounts.find( (a) => a.name === "Ayşe — Pluxee", )!; - expect((await find("ayse")).balanceMinor).toBe(3_120_00); + // Hesap sahibi açılış ve güncel bakiyeyi görür (güncel = açılış + işlemler). + expect((await find("ayse")).openingBalanceMinor).toBe(3_120_00); + expect((await find("ayse")).balanceMinor).not.toBeNull(); expect((await find("admin")).balanceMinor).toBeNull(); expect((await find("emre")).balanceMinor).toBeNull(); expect((await find("emre")).openingBalanceMinor).toBeNull(); @@ -104,7 +106,10 @@ describe("“yalnızca ben” hesabı", () => { ); const without = (await listAccounts(ids.user("emre"), cakir))!.totals; expect(without.excludesHidden).toBe(true); - expect(withHidden.assetsMinor - without.assetsMinor).toBe(3_120_00); + const pluxee = (await listAccounts(ids.user("ayse"), cakir))!.accounts.find( + (a) => a.name === "Ayşe — Pluxee", + )!; + expect(withHidden.assetsMinor - without.assetsMinor).toBe(pluxee.balanceMinor); // Hesap sahibi kendi hesabını her zaman toplamda görür. const ayse = (await listAccounts(ids.user("ayse"), cakir))!.totals; expect(ayse.assetsMinor).toBe(withHidden.assetsMinor); diff --git a/src/features/accounts/service.ts b/src/features/accounts/service.ts index 4833842..08c9de5 100644 --- a/src/features/accounts/service.ts +++ b/src/features/accounts/service.ts @@ -1,6 +1,7 @@ import "server-only"; import { Types } from "mongoose"; import { getHouseholdDetail, resolveMembership } from "@/features/households/service"; +import { computeAccountDeltas } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { diffFields, writeAudit } from "@/lib/audit"; @@ -94,12 +95,12 @@ type Lean = { const canSeeBalance = (a: Lean, viewerId: string) => !a.isPrivate || String(a.ownerUserId) === viewerId; -/** FAZ 6b: açılış bakiyesi + işlemler. Şimdilik açılış bakiyesi. */ -function computeBalance(a: Lean): number { - return a.openingBalanceMinor; +/** Güncel bakiye = açılış bakiyesi + gerçekleşmiş işlemlerin etkisi (tek doğruluk kaynağı: işlemler). */ +function computeBalance(a: Lean, deltas: ReadonlyMap): number { + return a.openingBalanceMinor + (deltas.get(String(a._id)) ?? 0); } -function toView(a: Lean, viewerId: string): AccountView { +function toView(a: Lean, viewerId: string, deltas: ReadonlyMap): AccountView { const visible = canSeeBalance(a, viewerId); return { id: String(a._id), @@ -113,7 +114,7 @@ function toView(a: Lean, viewerId: string): AccountView { archived: Boolean(a.archivedAt), isPrivate: a.isPrivate, ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null, - balanceMinor: visible ? computeBalance(a) : null, + balanceMinor: visible ? computeBalance(a, deltas) : null, openingBalanceMinor: visible ? a.openingBalanceMinor : null, openingDate: toIstanbulYmd(a.openingDate), overdraft: a.overdraft?.enabled ? a.overdraft : null, @@ -140,7 +141,10 @@ export async function listAccounts( const rows = (await AccountModel.find(filter).lean()).sort( (a, b) => a.order - b.order || compareTr(a.name, b.name), ); - const household = await HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(); + const [household, deltas] = await Promise.all([ + HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(), + computeAccountDeltas(m.householdId), + ]); const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true; const totals: AccountTotals = { @@ -158,12 +162,12 @@ export async function listAccounts( continue; } if (!visible) totals.includesHidden = true; - const balance = computeBalance(a); + const balance = computeBalance(a, deltas); if (isLiabilityType(a.type)) totals.cardDebtMinor += balance; else totals.assetsMinor += balance; } totals.netMinor = totals.assetsMinor + totals.cardDebtMinor; - return { accounts: rows.map((a) => toView(a, userId)), totals }; + return { accounts: rows.map((a) => toView(a, userId, deltas)), totals }; } /** Form → belge alanları. Kart için açılış borcu negatif saklanır. */ diff --git a/src/features/transactions/actions.ts b/src/features/transactions/actions.ts new file mode 100644 index 0000000..ffa62d8 --- /dev/null +++ b/src/features/transactions/actions.ts @@ -0,0 +1,105 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { transactionFormSchema } from "@/shared/schemas/transaction"; +import { + createTransaction, + getTransaction, + getTransactionFormOptions, + searchPayees, + setTransactionDeleted, + updateTransaction, + type PayeeSuggestion, + type TransactionDetail, + type TransactionFormOptions, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +/** zod hatalarını alan bazlı mesajlara çevirir ("splits.1.amount" gibi yollar korunur). */ +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +function revalidate() { + // Bakiye, liste ve (ileride) dashboard aynı veriden beslenir. + revalidatePath("/", "layout"); +} + +export async function createTransactionAction( + input: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = transactionFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await createTransaction(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidate(); + return result; +} + +export async function updateTransactionAction( + transactionId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + if (!id.success) return fail(tr.common.notFound); + const parsed = transactionFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await updateTransaction(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidate(); + return result; +} + +export async function setTransactionDeletedAction( + transactionId: unknown, + deleted: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + const d = z.boolean().safeParse(deleted); + if (!id.success || !d.success) return fail(tr.common.genericError); + const result = await setTransactionDeleted(ctx.user.id, ctx.household.id, id.data, d.data); + if (result.ok) revalidate(); + return result; +} + +/** Form açılırken: yazılabilir hesaplar ve kategoriler (hızlı ekle her ekrandan çağırır). */ +export async function getTransactionFormOptionsAction(): Promise< + ActionResult +> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const options = await getTransactionFormOptions(ctx.user.id, ctx.household.id); + return options ? ok(options) : fail(tr.common.unauthorized); +} + +export async function getTransactionAction( + transactionId: unknown, +): Promise> { + const ctx = await getHouseholdActionContext("viewer"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(transactionId); + if (!id.success) return fail(tr.common.notFound); + const detail = await getTransaction(ctx.user.id, ctx.household.id, id.data); + return detail ? ok(detail) : fail(tr.common.notFound); +} + +export async function searchPayeesAction(query: unknown): Promise { + const ctx = await getHouseholdActionContext("member"); + const q = z.string().max(80).safeParse(query); + if (!ctx || !q.success) return []; + return searchPayees(ctx.user.id, ctx.household.id, q.data); +} diff --git a/src/features/transactions/service.int.test.ts b/src/features/transactions/service.int.test.ts new file mode 100644 index 0000000..dd84b05 --- /dev/null +++ b/src/features/transactions/service.int.test.ts @@ -0,0 +1,434 @@ +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createAccount, listAccounts } from "@/features/accounts/service"; +import { setCategoryArchived } from "@/features/categories/service"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import { transactionFilterSchema, type TransactionForm } from "@/shared/schemas/transaction"; +import { transactionEffects } from "@/shared/transactions"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + createTransaction, + exportTransactions, + getTransaction, + listTransactions, + searchPayees, + setTransactionDeleted, + updateTransaction, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); +}); +afterAll(teardownDb); + +const acc = (name: string, householdId = cakir) => + AccountModel.findOne({ householdId, name }) + .lean() + .then((a) => String(a!._id)); +const cat = (key: string, householdId = cakir) => + CategoryModel.findOne({ householdId, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); +const filters = (x: Record = {}) => transactionFilterSchema.parse(x); + +function form(over: Partial): TransactionForm { + return { + type: "expense", + amount: 100_00, + date: "2026-06-15", + accountId: "", + toAccountId: null, + categoryId: null, + splits: [], + payee: "", + description: "", + tags: [], + ...over, + }; +} + +async function mustCreate(user: string, f: TransactionForm, householdId = cakir) { + const r = await createTransaction(ids.user(user), householdId, f); + if (!r.ok) throw new Error(`oluşturulamadı: ${r.error} ${JSON.stringify(r.fieldErrors)}`); + return r.data.id; +} + +describe("bakiye tutarlılığı", () => { + let bank = ""; + let card = ""; + + it("gelir, gider, kart harcaması, karta transfer ve bölünmüş işlem bakiyeye doğru yansır", async () => { + const base = { + institution: "", + mealCardProvider: null, + openingDate: "2026-01-01", + color: "blue" as const, + icon: "landmark" as const, + ownerUserId: null, + isPrivate: false, + overdraftEnabled: false, + overdraftLimit: 0, + overdraftMonthlyRate: null, + overdraftStatementDay: null, + cardLimit: 0, + cardStatementDay: 1, + cardDueKind: "daysAfter" as const, + cardDueValue: 10, + cardPaymentAccountId: null, + }; + const b = await createAccount(ids.user("ayse"), cakir, { + ...base, + name: "T Banka", + type: "bank", + openingBalance: 1_000_00, + }); + const c = await createAccount(ids.user("ayse"), cakir, { + ...base, + name: "T Kart", + type: "credit_card", + openingBalance: 0, + }); + bank = b.ok ? b.data.id : ""; + card = c.ok ? c.data.id : ""; + const gida = await cat("market.gida"); + const temizlik = await cat("market.temizlik"); + + await mustCreate( + "ayse", + form({ type: "income", amount: 500_00, accountId: bank, categoryId: await cat("ek-gelir") }), + ); + await mustCreate("ayse", form({ amount: 200_00, accountId: bank, categoryId: gida })); + await mustCreate("ayse", form({ amount: 300_00, accountId: card, categoryId: gida })); + await mustCreate( + "ayse", + form({ type: "transfer", amount: 300_00, accountId: bank, toAccountId: card }), + ); + await mustCreate( + "ayse", + form({ + amount: 120_00, + accountId: bank, + splits: [ + { categoryId: gida, amount: 80_00, note: "" }, + { categoryId: temizlik, amount: 40_00, note: "Deterjan" }, + ], + }), + ); + + const balances = (await listAccounts(ids.user("ayse"), cakir))!.accounts; + expect(balances.find((a) => a.id === bank)?.balanceMinor).toBe( + 1_000_00 + 500_00 - 200_00 - 300_00 - 120_00, + ); + expect(balances.find((a) => a.id === card)?.balanceMinor).toBe(-300_00 + 300_00); + }); + + it("silme bakiyeden düşer, geri alma geri getirir", async () => { + const id = await mustCreate( + "emre", + form({ amount: 50_00, accountId: bank, categoryId: await cat("market.gida") }), + ); + const bal = async () => + (await listAccounts(ids.user("ayse"), cakir))!.accounts.find((a) => a.id === bank)! + .balanceMinor; + const before = await bal(); + expect((await setTransactionDeleted(ids.user("emre"), cakir, id, true)).ok).toBe(true); + expect(await bal()).toBe(before! + 50_00); + expect(await getTransaction(ids.user("emre"), cakir, id)).toBeNull(); + expect((await setTransactionDeleted(ids.user("emre"), cakir, id, false)).ok).toBe(true); + expect(await bal()).toBe(before); + }); + + it("tüm hesaplarda bakiye = açılış + Σ transactionEffects (bağımsız doğrulama)", async () => { + const accounts = await AccountModel.find({ householdId: cakir }).lean(); + const txs = await TransactionModel.find({ householdId: cakir }).lean(); + const expected = new Map(accounts.map((a) => [String(a._id), a.openingBalanceMinor])); + for (const tx of txs) { + for (const e of transactionEffects({ + type: tx.type, + status: tx.status, + amountMinor: tx.amountMinor, + accountId: String(tx.accountId), + toAccountId: tx.toAccountId ? String(tx.toAccountId) : null, + deleted: Boolean(tx.deletedAt), + })) { + expected.set(e.accountId, (expected.get(e.accountId) ?? 0) + e.deltaMinor); + } + } + // ayse "yalnızca ben" hesabının sahibi; tüm bakiyeleri görür. + for (const a of (await listAccounts(ids.user("ayse"), cakir, { includeArchived: true }))! + .accounts) { + expect(a.balanceMinor, a.name).toBe(expected.get(a.id)); + } + }); +}); + +describe("doğrulama ve izolasyon", () => { + it("gider için gelir kategorisi, başka hanenin hesabı/kategorisi reddedilir", async () => { + const ziraat = await acc("Ziraat Maaş"); + const r1 = await createTransaction( + ids.user("ayse"), + cakir, + form({ accountId: ziraat, categoryId: await cat("maas") }), + ); + expect(r1.ok).toBe(false); + const r2 = await createTransaction( + ids.user("ayse"), + cakir, + form({ accountId: await acc("Nakit", annemler), categoryId: await cat("market.gida") }), + ); + expect(r2.ok).toBe(false); + const r3 = await createTransaction( + ids.user("ayse"), + cakir, + form({ accountId: ziraat, categoryId: await cat("market.gida", annemler) }), + ); + expect(r3.ok).toBe(false); + const r4 = await createTransaction( + ids.user("ayse"), + cakir, + form({ + accountId: ziraat, + splits: [ + { categoryId: await cat("market.gida"), amount: 50_00, note: "" }, + { categoryId: await cat("market.gida", annemler), amount: 50_00, note: "" }, + ], + }), + ); + expect(r4.ok).toBe(false); + const r5 = await createTransaction( + ids.user("ayse"), + cakir, + form({ + type: "transfer", + accountId: ziraat, + toAccountId: await acc("Vakıfbank Emekli", annemler), + }), + ); + expect(r5.ok).toBe(false); + }); + + it("izleyici işlem giremez; başka hane üyesi işlemleri göremez", async () => { + const r = await createTransaction( + ids.user("zeynep"), + cakir, + form({ accountId: await acc("Cüzdan"), categoryId: await cat("market.gida") }), + ); + expect(r.ok).toBe(false); + expect(await listTransactions(ids.user("fatma"), cakir, filters())).toBeNull(); + const cakirTx = await TransactionModel.findOne({ householdId: cakir }).lean(); + // fatma kendi hane kimliğiyle Çakır'ın işlemini isterse bulunamaz. + expect(await getTransaction(ids.user("fatma"), annemler, String(cakirTx!._id))).toBeNull(); + expect( + (await setTransactionDeleted(ids.user("fatma"), annemler, String(cakirTx!._id), true)).ok, + ).toBe(false); + }); +}); + +describe("“yalnızca ben” hesabı işlemleri", () => { + it("başkasından tamamen gizlenir; o hesaba yalnızca sahibi işlem girer", async () => { + const pluxee = await acc("Ayşe — Pluxee"); + const tx = await TransactionModel.findOne({ householdId: cakir, accountId: pluxee }).lean(); + const ofEmre = await listTransactions(ids.user("emre"), cakir, filters({ hesap: pluxee })); + expect(ofEmre!.total).toBe(0); + expect(await getTransaction(ids.user("emre"), cakir, String(tx!._id))).toBeNull(); + const ofAyse = await listTransactions(ids.user("ayse"), cakir, filters({ hesap: pluxee })); + expect(ofAyse!.total).toBeGreaterThan(0); + const exportEmre = await exportTransactions(ids.user("emre"), cakir, filters()); + expect(exportEmre!.some((r) => r.account === "Ayşe — Pluxee")).toBe(false); + + const kafe = await cat("eglence.kafe"); + expect( + ( + await createTransaction( + ids.user("emre"), + cakir, + form({ accountId: pluxee, categoryId: kafe }), + ) + ).ok, + ).toBe(false); + expect( + ( + await updateTransaction( + ids.user("emre"), + cakir, + String(tx!._id), + form({ accountId: pluxee, categoryId: kafe }), + ) + ).ok, + ).toBe(false); + expect( + ( + await createTransaction( + ids.user("ayse"), + cakir, + form({ accountId: pluxee, categoryId: kafe }), + ) + ).ok, + ).toBe(true); + }); +}); + +describe("arşivli kategori", () => { + it("yeni işlemde kullanılamaz, mevcut işlemde korunur", async () => { + const giyim = await cat("giyim"); + const cuzdan = await acc("Cüzdan"); + const id = await mustCreate("ayse", form({ accountId: cuzdan, categoryId: giyim })); + expect((await setCategoryArchived(ids.user("ayse"), cakir, giyim, true)).ok).toBe(true); + expect( + ( + await createTransaction( + ids.user("ayse"), + cakir, + form({ accountId: cuzdan, categoryId: giyim }), + ) + ).ok, + ).toBe(false); + expect( + ( + await updateTransaction( + ids.user("ayse"), + cakir, + id, + form({ accountId: cuzdan, categoryId: giyim, description: "düzeltme" }), + ) + ).ok, + ).toBe(true); + await setCategoryArchived(ids.user("ayse"), cakir, giyim, false); + }); +}); + +describe("filtreler", () => { + it("üst kategori filtresi alt kategorileri ve bölünmüş kalemleri kapsar", async () => { + const market = await cat("market"); + const page = await listTransactions(ids.user("ayse"), cakir, filters({ kategori: market })); + expect(page!.total).toBe( + await TransactionModel.countDocuments({ + householdId: cakir, + deletedAt: null, + effectiveCategoryIds: { + $in: await CategoryModel.find({ + householdId: cakir, + $or: [{ _id: market }, { ancestors: new Types.ObjectId(market) }], + }).distinct("_id"), + }, + }), + ); + expect(page!.items.some((i) => i.splitCount > 0)).toBe(true); + }); + + it("tarih aralığı İstanbul gününe göre (gün dahil)", async () => { + const id = await mustCreate( + "ayse", + form({ + date: "2026-01-15", + accountId: await acc("Cüzdan"), + categoryId: await cat("market.gida"), + payee: "Tarih Testi", + }), + ); + const same = await listTransactions( + ids.user("ayse"), + cakir, + filters({ from: "2026-01-15", to: "2026-01-15", q: "tarih testi" }), + ); + expect(same!.items.map((i) => i.id)).toEqual([id]); + const after = await listTransactions( + ids.user("ayse"), + cakir, + filters({ from: "2026-01-16", q: "tarih testi" }), + ); + expect(after!.total).toBe(0); + }); + + it("Türkçe arama, tutar aralığı, kişi, etiket ve hesap (transfer hedefi dahil)", async () => { + const q = await listTransactions(ids.user("ayse"), cakir, filters({ q: "MİGROS" })); + expect(q!.total).toBeGreaterThan(0); + expect(q!.items.every((i) => i.payeeName === "Migros")).toBe(true); + + const range = await listTransactions( + ids.user("ayse"), + cakir, + filters({ min: "1.000", max: "2.000" }), + ); + expect(range!.items.every((i) => i.amountMinor >= 1_000_00 && i.amountMinor <= 2_000_00)).toBe( + true, + ); + + const byEmre = await listTransactions( + ids.user("ayse"), + cakir, + filters({ kisi: ids.user("emre") }), + ); + expect(byEmre!.items.every((i) => i.createdBy.id === ids.user("emre"))).toBe(true); + + const tagged = await listTransactions(ids.user("ayse"), cakir, filters({ etiket: "İndirim" })); + expect(tagged!.items.every((i) => i.tags.includes("indirim"))).toBe(true); + + const cuzdan = await acc("Cüzdan"); + const byAccount = await listTransactions( + ids.user("ayse"), + cakir, + filters({ hesap: cuzdan, type: "transfer" }), + ); + expect(byAccount!.total).toBeGreaterThan(0); + expect( + byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account.id === cuzdan), + ).toBe(true); + }); + + it("geçersiz filtre değerleri yok sayılır (enjeksiyon yok)", async () => { + const f = transactionFilterSchema.parse({ + hesap: "{$ne:null}", + kategori: "x", + min: "abc", + sayfa: "-3", + q: ".*", + }); + expect(f.hesap).toBeUndefined(); + expect(f.kategori).toBeUndefined(); + expect(f.min).toBeUndefined(); + expect(f.sayfa).toBe(1); + // ".*" düz metin olarak aranır (regex kaçışı). + expect((await listTransactions(ids.user("ayse"), cakir, f))!.total).toBe(0); + }); +}); + +describe("satıcı kuralları", () => { + it("son kullanılan kategori ve hesap önerilir", async () => { + const cuzdan = await acc("Cüzdan"); + const ilac = await cat("saglik.ilac"); + await mustCreate("ayse", form({ accountId: cuzdan, categoryId: ilac, payee: "Şifa Eczanesi" })); + const [s] = await searchPayees(ids.user("ayse"), cakir, "sifa"); + expect(s).toMatchObject({ + name: "Şifa Eczanesi", + lastAccountId: cuzdan, + lastCategoryId: ilac, + lastType: "expense", + }); + // Başka hane satıcıyı görmez. + expect(await searchPayees(ids.user("fatma"), annemler, "sifa")).toEqual([]); + }); +}); + +describe("CSV", () => { + it("bölünmüş işlem kalem başına satır üretir, işaret türe göre", async () => { + const rows = (await exportTransactions(ids.user("ayse"), cakir, filters()))!; + const split = await TransactionModel.findOne({ + householdId: cakir, + "splits.1": { $exists: true }, + deletedAt: null, + }).lean(); + const lines = rows.filter((r) => r.no === String(split!._id)); + expect(lines).toHaveLength(split!.splits.length); + expect(lines.reduce((a, r) => a + r.amountMinor, 0)).toBe(-split!.amountMinor); + expect(rows.find((r) => r.type === "income")!.amountMinor).toBeGreaterThan(0); + }); +}); diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts new file mode 100644 index 0000000..4869cd7 --- /dev/null +++ b/src/features/transactions/service.ts @@ -0,0 +1,727 @@ +import "server-only"; +import { Types, type PipelineStage } from "mongoose"; +import { resolveMembership } from "@/features/households/service"; +import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { getDb } from "@/lib/db/mongo"; +import { toObjectId } from "@/lib/object-id"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { PayeeModel } from "@/models/payee"; +import { TransactionModel } from "@/models/transaction"; +import type { AccountType } from "@/shared/accounts"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; +import type { CategoryKind } from "@/shared/schemas/category"; +import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction"; +import { compareTr, foldTr } from "@/shared/search"; +import { normalizeTags, type TransactionStatus, type TransactionType } from "@/shared/transactions"; + +/** + * İşlem servisi (Bölüm 6.3). Okuma: viewer; yazma: member. + * Başkasının "yalnızca ben" hesabına dokunan işlemler o kullanıcıdan tamamen gizlenir + * (işlemler bakiyeyi ele verir) ve bu hesaplara yalnızca hesap sahibi işlem girebilir. + */ + +const t = tr.transactions; +export const PAGE_SIZE = 25; +const DAY_MS = 24 * 60 * 60 * 1000; + +// --- Hane bağlamı: hesaplar, kategoriler, görünürlük --- + +type AccountInfo = { + id: Types.ObjectId; + name: string; + type: AccountType; + archived: boolean; + hidden: boolean; + icon: IconKey; + color: Swatch; +}; + +type CategoryInfo = { + id: Types.ObjectId; + kind: CategoryKind; + name: string; + path: string; + archived: boolean; + icon: IconKey; + color: Swatch; + ancestors: string[]; +}; + +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([ + AccountModel.find({ householdId: m.householdId }) + .select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 }) + .lean(), + CategoryModel.find({ householdId: m.householdId }) + .select({ + name: 1, + kind: 1, + parentId: 1, + order: 1, + archivedAt: 1, + icon: 1, + color: 1, + ancestors: 1, + }) + .lean(), + ]); + const accountMap = new Map( + accounts.map((a) => [ + String(a._id), + { + id: a._id, + name: a.name, + type: a.type, + archived: Boolean(a.archivedAt), + hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId, + icon: a.icon as IconKey, + color: a.color as Swatch, + }, + ]), + ); + const flat = categories.map((c) => ({ + id: String(c._id), + kind: c.kind, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: Boolean(c.archivedAt), + })); + const paths = new Map( + toOptions(buildTree(flat), { includeArchived: true }).map((o) => [o.id, o.path]), + ); + const categoryMap = new Map( + categories.map((c) => [ + String(c._id), + { + id: c._id, + kind: c.kind, + name: c.name, + path: paths.get(String(c._id)) ?? c.name, + archived: Boolean(c.archivedAt), + icon: c.icon as IconKey, + color: c.color as Swatch, + ancestors: c.ancestors.map(String), + }, + ]), + ); + const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id); + return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat }; +} + +type Ctx = NonNullable>>; + +/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */ +function baseFilter(ctx: Ctx): Record { + const f: Record = { householdId: ctx.m.householdId, deletedAt: null }; + if (ctx.hiddenAccountIds.length) { + f.accountId = { $nin: ctx.hiddenAccountIds }; + f.toAccountId = { $nin: ctx.hiddenAccountIds }; + } + return f; +} + +// --- Bakiye --- + +/** + * Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler. + * transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +. + */ +export async function computeAccountDeltas( + householdId: Types.ObjectId, +): Promise> { + const pipeline: PipelineStage[] = [ + { $match: { householdId, status: "posted", deletedAt: null } }, + { + $project: { + effects: [ + { + a: "$accountId", + d: { + $cond: [ + { $eq: ["$type", "income"] }, + "$amountMinor", + { $multiply: ["$amountMinor", -1] }, + ], + }, + }, + { + a: { $cond: [{ $eq: ["$type", "transfer"] }, "$toAccountId", null] }, + d: "$amountMinor", + }, + ], + }, + }, + { $unwind: "$effects" }, + { $match: { "effects.a": { $ne: null } } }, + { $group: { _id: "$effects.a", delta: { $sum: "$effects.d" } } }, + ]; + const rows = await TransactionModel.aggregate<{ _id: Types.ObjectId; delta: number }>(pipeline); + return new Map(rows.map((r) => [String(r._id), r.delta])); +} + +// --- Doğrulama --- + +function checkAccount(ctx: Ctx, id: string | null, keep?: string | null): Types.ObjectId | null { + if (!id) return null; + const a = ctx.accountMap.get(id); + // Düzenlemede işlemin zaten bağlı olduğu arşivli hesap korunabilir. + if (!a || a.hidden || (a.archived && id !== keep)) return null; + return a.id; +} + +function checkCategory( + ctx: Ctx, + id: string, + kind: CategoryKind, + keep: Set, +): Types.ObjectId | null { + const c = ctx.categoryMap.get(id); + if (!c || c.kind !== kind || (c.archived && !keep.has(id))) return null; + return c.id; +} + +type Existing = { + accountId: Types.ObjectId; + toAccountId?: Types.ObjectId | null; + categoryId?: Types.ObjectId | null; + splits?: { categoryId: Types.ObjectId }[]; +}; + +function buildDoc( + ctx: Ctx, + input: TransactionForm, + existing?: Existing, +): ActionResult> { + const date = istanbulDayStart(input.date); + if (!date) return fail(tr.validation.date, { date: tr.validation.date }); + + const accountId = checkAccount( + ctx, + input.accountId, + existing ? String(existing.accountId) : null, + ); + if (!accountId) + return fail(t.validation.accountInvalid, { accountId: t.validation.accountInvalid }); + + let toAccountId: Types.ObjectId | null = null; + let categoryId: Types.ObjectId | null = null; + let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = []; + + if (input.type === "transfer") { + toAccountId = checkAccount( + ctx, + input.toAccountId, + existing?.toAccountId ? String(existing.toAccountId) : null, + ); + if (!toAccountId || toAccountId.equals(accountId)) { + return fail(t.validation.accountInvalid, { toAccountId: t.validation.accountInvalid }); + } + } else { + const kind: CategoryKind = input.type; + const keep = new Set( + [existing?.categoryId, ...(existing?.splits ?? []).map((s) => s.categoryId)] + .filter(Boolean) + .map(String), + ); + if (input.splits.length >= 2) { + for (const [i, s] of input.splits.entries()) { + const cid = checkCategory(ctx, s.categoryId, kind, keep); + if (!cid) + return fail(t.validation.categoryInvalid, { + [`splits.${i}.categoryId`]: t.validation.categoryInvalid, + }); + splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note }); + } + } else { + categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null; + if (!categoryId) + return fail(t.validation.categoryInvalid, { categoryId: t.validation.categoryInvalid }); + splits = []; + } + } + + const effective = [ + ...new Set([categoryId, ...splits.map((s) => s.categoryId)].filter(Boolean).map(String)), + ].map((id) => new Types.ObjectId(id)); + const payeeName = input.payee.trim(); + return ok({ + type: input.type, + date, + accountId, + toAccountId, + amountMinor: input.amount, + categoryId, + splits, + effectiveCategoryIds: effective, + payeeName, + description: input.description, + searchText: foldTr([payeeName, input.description, ...splits.map((s) => s.note)].join(" ")), + tags: normalizeTags(input.tags), + }); +} + +/** Satıcı kuralını günceller (son kategori ve hesap) ve kimliğini döner. */ +async function touchPayee( + householdId: Types.ObjectId, + name: string, + type: TransactionType, + accountId: Types.ObjectId, + categoryId: Types.ObjectId | null, +): Promise { + if (!name) return null; + const doc = await PayeeModel.findOneAndUpdate( + { householdId, nameKey: foldTr(name) }, + { + $setOnInsert: { householdId, nameKey: foldTr(name) }, + $set: { + name, + lastType: type, + lastAccountId: accountId, + lastCategoryId: categoryId, + lastUsedAt: new Date(), + }, + $inc: { useCount: 1 }, + }, + { upsert: true, returnDocument: "after" }, + ).lean(); + return doc?._id ?? null; +} + +// --- Yazma --- + +export async function createTransaction( + actorId: string, + householdId: string, + input: TransactionForm, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const built = buildDoc(ctx, input); + if (!built.ok) return built; + const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; + // Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar. + const payeeId = await touchPayee( + ctx.m.householdId, + input.payee, + input.type, + d.accountId, + d.categoryId, + ); + const actor = new Types.ObjectId(actorId); + const doc = await TransactionModel.create({ + ...built.data, + householdId: ctx.m.householdId, + status: "posted", + payeeId, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +async function loadOwnTransaction( + ctx: Ctx, + transactionId: string, + { includeDeleted = false } = {}, +) { + const id = toObjectId(transactionId); + if (!id) return null; + const filter: Record = { ...baseFilter(ctx), _id: id }; + if (includeDeleted) delete filter.deletedAt; + return TransactionModel.findOne(filter).lean(); +} + +export async function updateTransaction( + actorId: string, + householdId: string, + transactionId: string, + input: TransactionForm, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const current = await loadOwnTransaction(ctx, transactionId); + if (!current) return fail(tr.common.notFound); + const built = buildDoc(ctx, input, current); + if (!built.ok) return built; + const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null }; + const payeeId = await touchPayee( + ctx.m.householdId, + input.payee, + input.type, + d.accountId, + d.categoryId, + ); + await TransactionModel.updateOne( + { _id: current._id, householdId: ctx.m.householdId }, + { $set: { ...built.data, payeeId, updatedBy: new Types.ObjectId(actorId) } }, + ); + return ok(undefined); +} + +export async function setTransactionDeleted( + actorId: string, + householdId: string, + transactionId: string, + deleted: boolean, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true }); + if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await TransactionModel.updateOne( + { _id: current._id, householdId: ctx.m.householdId }, + { $set: { deletedAt: deleted ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: deleted ? "transaction.delete" : "transaction.restore", + actorId: actor, + householdId: ctx.m.householdId, + targetType: "transaction", + targetId: transactionId, + diff: { + type: current.type, + amountMinor: current.amountMinor, + date: toIstanbulYmd(current.date), + }, + }); + return ok(undefined); +} + +// --- Okuma --- + +export type TransactionListItem = { + id: string; + type: TransactionType; + status: TransactionStatus; + date: string; + amountMinor: number; + account: { id: string; name: string }; + toAccount: { id: string; name: string } | null; + category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null; + splitCount: number; + payeeName: string; + description: string; + tags: string[]; + createdBy: { id: string; name: string }; + attachmentCount: number; +}; + +export type TransactionDetail = TransactionListItem & { + splits: { + categoryId: string; + path: string; + icon: IconKey; + color: Swatch; + amountMinor: number; + note: string; + }[]; + updatedBy: { id: string; name: string }; + createdAt: string; + updatedAt: string; + /** Düzenleme formu için ham kimlikler. */ + categoryId: string | null; +}; + +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") + .find({ _id: { $in: unique } }, { projection: { name: 1 } }) + .toArray(); + return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")])); +} + +type LeanTx = Awaited>[number] & { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +}; + +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)); + return { id: String(id), name: a?.name ?? "—" }; + }; + const cat = tx.categoryId ? ctx.categoryMap.get(String(tx.categoryId)) : undefined; + return { + id: String(tx._id), + type: tx.type, + status: tx.status, + date: toIstanbulYmd(tx.date), + amountMinor: tx.amountMinor, + account: acc(tx.accountId)!, + toAccount: acc(tx.toAccountId), + category: cat + ? { id: String(cat.id), name: cat.name, path: cat.path, icon: cat.icon, color: cat.color } + : null, + splitCount: tx.splits?.length ?? 0, + payeeName: tx.payeeName ?? "", + description: tx.description ?? "", + tags: tx.tags ?? [], + createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" }, + attachmentCount: tx.attachmentCount ?? 0, + }; +} + +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function filterQuery(ctx: Ctx, f: TransactionFilters): Record { + const q: Record = baseFilter(ctx); + const and: Record[] = []; + const from = f.from ? istanbulDayStart(f.from) : null; + const to = f.to ? istanbulDayStart(f.to) : null; + if (from || to) { + q.date = { + ...(from ? { $gte: from } : {}), + ...(to ? { $lt: new Date(to.getTime() + DAY_MS) } : {}), + }; + } + if (f.type) q.type = f.type; + if (f.durum) q.status = f.durum; + if (f.hesap) { + const id = toObjectId(f.hesap); + // Gizli hesap filtresi sonuç vermez (baseFilter zaten dışlar). + and.push({ $or: [{ accountId: id }, { toAccountId: id }] }); + } + if (f.kategori) { + // Alt kategoriler dahil. + const ids = [...ctx.categoryMap.values()] + .filter((c) => String(c.id) === f.kategori || c.ancestors.includes(f.kategori!)) + .map((c) => c.id); + q.effectiveCategoryIds = { $in: ids }; + } + if (f.kisi) q.createdBy = toObjectId(f.kisi); + if (f.min !== undefined || f.max !== undefined) { + q.amountMinor = { + ...(f.min !== undefined ? { $gte: f.min } : {}), + ...(f.max !== undefined ? { $lte: f.max } : {}), + }; + } + if (f.etiket) q.tags = normalizeTags(f.etiket)[0] ?? ""; + if (f.q) q.searchText = { $regex: escapeRegex(foldTr(f.q)) }; + if (and.length) q.$and = and; + return q; +} + +export type TransactionPage = { + items: TransactionListItem[]; + total: number; + page: number; + pageCount: number; + sums: { incomeMinor: number; expenseMinor: number }; +}; + +export async function listTransactions( + userId: string, + householdId: string, + filters: TransactionFilters, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const query = filterQuery(ctx, filters); + const [total, sumRows] = await Promise.all([ + TransactionModel.countDocuments(query), + TransactionModel.aggregate<{ _id: TransactionType; sum: number }>([ + { + $match: { + ...query, + status: query.status ?? "posted", + type: query.type ?? { $in: ["income", "expense"] }, + }, + }, + { $group: { _id: "$type", sum: { $sum: "$amountMinor" } } }, + ]), + ]); + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(filters.sayfa, pageCount); + const rows = (await TransactionModel.find(query) + .sort({ date: -1, createdAt: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .lean()) as unknown as LeanTx[]; + const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); + return { + items: rows.map((r) => toItem(ctx, r, names)), + total, + page, + pageCount, + sums: { + incomeMinor: sumRows.find((r) => r._id === "income")?.sum ?? 0, + expenseMinor: sumRows.find((r) => r._id === "expense")?.sum ?? 0, + }, + }; +} + +export async function getTransaction( + userId: string, + householdId: string, + transactionId: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + 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]); + return { + ...toItem(ctx, tx, names), + categoryId: tx.categoryId ? String(tx.categoryId) : null, + splits: (tx.splits ?? []).map((s) => { + const c = ctx.categoryMap.get(String(s.categoryId)); + return { + categoryId: String(s.categoryId), + path: c?.path ?? "—", + icon: c?.icon ?? "tag", + color: c?.color ?? "slate", + amountMinor: s.amountMinor, + note: s.note ?? "", + }; + }), + updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" }, + createdAt: new Date(tx.createdAt).toISOString(), + updatedAt: new Date(tx.updatedAt).toISOString(), + }; +} + +/** CSV için tüm eşleşen işlemler (sayfalama yok; üst sınır 20.000). Bölünmüş işlem kalem başına satır. */ +export async function exportTransactions( + userId: string, + householdId: string, + filters: TransactionFilters, +) { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const rows = await TransactionModel.find(filterQuery(ctx, filters)) + .sort({ date: -1, createdAt: -1 }) + .limit(20_000) + .lean(); + const names = await userNames(rows.map((r) => r.createdBy as Types.ObjectId)); + const accountName = (id: Types.ObjectId | null | undefined) => + id ? (ctx.accountMap.get(String(id))?.name ?? "") : ""; + const categoryPath = (id: Types.ObjectId | null | undefined) => + id ? (ctx.categoryMap.get(String(id))?.path ?? "") : ""; + const sign = (type: TransactionType) => (type === "income" ? 1 : type === "expense" ? -1 : 1); + const out: { + no: string; + date: string; + type: TransactionType; + status: TransactionStatus; + account: string; + toAccount: string; + category: string; + payee: string; + description: string; + note: string; + tags: string; + amountMinor: number; + createdBy: string; + }[] = []; + for (const r of rows) { + const common = { + no: String(r._id), + date: toIstanbulYmd(r.date), + type: r.type, + status: r.status, + account: accountName(r.accountId), + toAccount: accountName(r.toAccountId), + payee: r.payeeName ?? "", + description: r.description ?? "", + tags: (r.tags ?? []).join(", "), + createdBy: names.get(String(r.createdBy)) ?? "", + }; + if (r.splits?.length) { + for (const s of r.splits) { + out.push({ + ...common, + category: categoryPath(s.categoryId), + note: s.note ?? "", + amountMinor: sign(r.type) * s.amountMinor, + }); + } + } else { + out.push({ + ...common, + category: categoryPath(r.categoryId), + note: "", + amountMinor: sign(r.type) * r.amountMinor, + }); + } + } + return out; +} + +// --- Form seçenekleri ve satıcı önerileri --- + +export type TransactionFormOptions = { + accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[]; + categories: CategoryOption[]; + categoryAppearance: Record; +}; + +export async function getTransactionFormOptions( + userId: string, + householdId: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const accounts = ( + await AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null }) + .select({ name: 1, type: 1, icon: 1, color: 1, order: 1, isPrivate: 1, ownerUserId: 1 }) + .lean() + ) + .filter((a) => !a.isPrivate || String(a.ownerUserId) === userId) + .sort((a, b) => a.order - b.order || compareTr(a.name, b.name)) + .map((a) => ({ + id: String(a._id), + name: a.name, + type: a.type, + icon: a.icon as IconKey, + color: a.color as Swatch, + })); + const categories = toOptions(buildTree(ctx.flatCategories)); + const categoryAppearance = Object.fromEntries( + [...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]), + ); + return { accounts, categories, categoryAppearance }; +} + +export type PayeeSuggestion = { + name: string; + lastType: TransactionType; + lastCategoryId: string | null; + lastAccountId: string | null; +}; + +/** Satıcı önerileri: ada göre (Türkçe normalize, baştan veya içinden), kullanım sıklığına göre. */ +export async function searchPayees( + userId: string, + householdId: string, + query: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return []; + const key = foldTr(query); + const filter: Record = { householdId: ctx.m.householdId }; + if (key) filter.nameKey = { $regex: escapeRegex(key) }; + const rows = await PayeeModel.find(filter).sort({ useCount: -1, lastUsedAt: -1 }).limit(8).lean(); + return rows.map((p) => { + const acc = p.lastAccountId ? ctx.accountMap.get(String(p.lastAccountId)) : undefined; + const cat = p.lastCategoryId ? ctx.categoryMap.get(String(p.lastCategoryId)) : undefined; + return { + name: p.name, + lastType: p.lastType, + // Öneri yalnızca hâlâ kullanılabilir hesap/kategoriyi gösterir. + lastAccountId: acc && !acc.hidden && !acc.archived ? String(acc.id) : null, + lastCategoryId: cat && !cat.archived ? String(cat.id) : null, + }; + }); +} diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 37a9484..c52b775 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -24,6 +24,8 @@ export const AUDIT_ACTIONS = [ "category.archive", "category.unarchive", "category.move", + "transaction.delete", + "transaction.restore", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/payee.ts b/src/models/payee.ts new file mode 100644 index 0000000..1afbb39 --- /dev/null +++ b/src/models/payee.ts @@ -0,0 +1,25 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { TRANSACTION_TYPES } from "@/shared/transactions"; + +/** Satıcı / karşı taraf ve "akıllı giriş" kuralı: son kullanılan kategori ve hesap (Bölüm 6.3). */ +const payeeSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + nameKey: { type: String, required: true }, + lastType: { type: String, enum: TRANSACTION_TYPES, default: "expense" }, + lastCategoryId: { type: Schema.Types.ObjectId, default: null }, + lastAccountId: { type: Schema.Types.ObjectId, default: null }, + useCount: { type: Number, default: 0 }, + lastUsedAt: { type: Date, default: () => new Date() }, + }, + { timestamps: true, collection: "payees" }, +); + +payeeSchema.index({ householdId: 1, nameKey: 1 }, { unique: true }); +payeeSchema.index({ householdId: 1, useCount: -1 }); + +export type Payee = InferSchemaType; + +export const PayeeModel: Model = + (models.Payee as Model | undefined) ?? model("Payee", payeeSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts new file mode 100644 index 0000000..ac1e83c --- /dev/null +++ b/src/models/transaction.ts @@ -0,0 +1,75 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions"; + +/** + * İşlem (Bölüm 6.3). `amountMinor` her zaman pozitif; işaret türden gelir (transactionEffects). + * Bakiyenin tek doğruluk kaynağıdır. Silme yumuşaktır (`deletedAt`). + */ +const splitSchema = new Schema( + { + categoryId: { type: Schema.Types.ObjectId, required: true }, + amountMinor: { type: Number, required: true, min: 1 }, + note: { type: String, default: "", maxlength: 120 }, + }, + { _id: true }, +); + +const transactionSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + type: { type: String, enum: TRANSACTION_TYPES, required: true }, + status: { type: String, enum: TRANSACTION_STATUSES, default: "posted" }, + /** İstanbul gününün başlangıç anı (UTC). */ + date: { type: Date, required: true }, + accountId: { type: Schema.Types.ObjectId, required: true }, + toAccountId: { type: Schema.Types.ObjectId, default: null }, + amountMinor: { type: Number, required: true, min: 1 }, + /** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */ + categoryId: { type: Schema.Types.ObjectId, default: null }, + splits: { type: [splitSchema], default: [] }, + /** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */ + effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] }, + payeeId: { type: Schema.Types.ObjectId, default: null }, + payeeName: { type: String, default: "", maxlength: 80 }, + description: { type: String, default: "", maxlength: 500 }, + /** Arama için normalize metin (açıklama + satıcı + kalem notları). */ + searchText: { type: String, default: "" }, + tags: { type: [String], default: [] }, + 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 }, + installment: { + type: new Schema( + { planId: Schema.Types.ObjectId, index: Number, count: Number }, + { _id: false }, + ), + default: null, + }, + statementId: { type: Schema.Types.ObjectId, default: null }, + contactId: { type: Schema.Types.ObjectId, default: null }, + reconciledAt: { type: Date, default: null }, + deletedAt: { type: Date, default: null }, + /** Seed verisinin idempotent yüklenmesi için sabit anahtar. */ + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "transactions" }, +); + +transactionSchema.index({ householdId: 1, deletedAt: 1, date: -1, createdAt: -1 }); +transactionSchema.index({ householdId: 1, status: 1, date: 1 }); +transactionSchema.index({ householdId: 1, accountId: 1, date: -1 }); +transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 }); +transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 }); +transactionSchema.index({ householdId: 1, tags: 1 }); +transactionSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type Transaction = InferSchemaType; + +export const TransactionModel: Model = + (models.Transaction as Model | undefined) ?? + model("Transaction", transactionSchema);