diff --git a/CHANGELOG.md b/CHANGELOG.md index aabcf87..5559216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır Faz 6 (alt fazlar halinde; `v0.6.0` Faz 6c sonunda). +### Eklendi — Faz 6b: işlemler + +- İşlemler (`/islemler`): gelir, gider ve transfer (hesap ↔ hesap). Tutar her zaman pozitif; işaret türden gelir. Hesap bakiyeleri artık açılış bakiyesi + gerçekleşmiş işlemlerden hesaplanır (tek doğruluk kaynağı: işlemler). +- Bölünmüş işlem: en az 2 kalem, kalemler toplamı = tutar (istemci ve sunucuda aynı zod şeması); kalan tutar göstergesi, "kalanı son kaleme ekle"; listede tek satır, ayrıntıda kalemler. +- Akıllı giriş: satıcı önerileri (Türkçe normalize arama, kullanım sıklığı) ve satıcının son kullanılan kategori/hesabı; işlemi kopyalama. +- Liste: güne göre gruplama ve günlük net, gelir/gider/net özetleri, sayfalama; filtreler (tarih aralığı İstanbul gününe göre, tür, durum, hesap — transfer hedefi dahil, kategori — alt kategoriler ve bölünmüş kalemler dahil, ekleyen kişi, tutar aralığı, etiket, metin arama); filtreler URL'de. +- İşlem ayrıntısı, düzenleme ve yumuşak silme; silme bildiriminde "Geri al". +- CSV dışa aktarma (`/islemler/disa-aktar`): listedeki filtrelerle, bölünmüş işlem kalem başına satır; Türkçe Excel uyumlu (noktalı virgül, ondalık virgül, UTF-8 BOM), formül enjeksiyonu koruması. +- Hızlı ekle her ekrandan doğrudan işlem formunu açar (gider/gelir/transfer sekmeleri). +- Gizlilik: başkasının "yalnızca ben" hesabına dokunan işlemler listede, ayrıntıda ve CSV'de görünmez; o hesaba yalnızca sahibi işlem girer. +- Seed: son 6 ayın 221 gerçekçi işlemi (faker.seed(42); maaş, kira, faturalar — kışın doğalgaz yüksek, market — bir kısmı bölünmüş, yakıt, kafe, kart ödemesi, birikim, nakit çekme), satıcı kuralları; idempotent (`seedRef`). +- `003` migration'ı: `transactions` ve `payees` index'leri. Denetim kaydı: işlem silme/geri alma. +- Testler: bakiye tutarlılığı (bağımsız doğrulama dahil), izolasyon, gizli hesap, arşivli kategori, filtreler, satıcı kuralları, CSV. + +### Değişti + +- `npm run typecheck` önce route tiplerini üretir (`next typegen`). + ### Eklendi — Faz 6a: para, hesaplar ve kategoriler - `shared/money.ts`: Türkçe tutar ayrıştırma (`"1.250,50"` → 125050 kuruş; binlik/ondalık kuralları belgeli), `formatMoneyInput`, `sumMoney`, kuruş kaybı olmadan oransal bölme (`allocate`, `splitEvenly`). diff --git a/package.json b/package.json index 11ad446..489c51e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit", + "typecheck": "next typegen && tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "format": "prettier --write .", diff --git a/scripts/migrations/003-transactions.ts b/scripts/migrations/003-transactions.ts new file mode 100644 index 0000000..58cd95f --- /dev/null +++ b/scripts/migrations/003-transactions.ts @@ -0,0 +1,12 @@ +import { PayeeModel } from "@/models/payee"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "003-transactions", + description: "transactions ve payees index'leri", + async up() { + await TransactionModel.createIndexes(); + await PayeeModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index b92e442..78b0ab2 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -2,10 +2,11 @@ import { getDb } from "@/lib/db/mongo"; 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 type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001, m002]; +export const MIGRATIONS: Migration[] = [m001, m002, m003]; const COLLECTION = "migrations"; diff --git a/scripts/seed.ts b/scripts/seed.ts index b45a035..5e05fcf 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -11,9 +11,9 @@ import { closeSeedConnections, runSeed } from "./seed/run"; async function main() { const summary = await runSeed({ reset: process.argv.includes("--reset") }); - const { users, households, memberships, categories, accounts } = summary.created; + const { users, households, memberships, categories, accounts, transactions } = summary.created; console.log( - `\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap.`, + `\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap, ${transactions} işlem.`, ); // Hesap tablosu yalnızca geliştirme ortamında gösterilir. diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index bc0032c..b720dc8 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -31,6 +31,7 @@ describe("seed", () => { }); expect(summary.created.categories).toBeGreaterThan(40); expect(summary.created.accounts).toBe(7); + expect(summary.created.transactions).toBeGreaterThan(150); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -43,6 +44,7 @@ describe("seed", () => { memberships: 0, categories: 0, accounts: 0, + transactions: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index c8d1d00..bc84c64 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -9,6 +9,7 @@ import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; +import { seedTransactions } from "./transactions"; import { ADMIN_USERNAME, SEED_ACCOUNTS, @@ -40,6 +41,7 @@ export type SeedSummary = { memberships: number; categories: number; accounts: number; + transactions: number; }; accounts: SeedAccountRow[]; }; @@ -93,7 +95,14 @@ export async function runSeed({ const db = getDb(); await connectMongoose(); const summary: SeedSummary = { - created: { users: 0, households: 0, memberships: 0, categories: 0, accounts: 0 }, + created: { + users: 0, + households: 0, + memberships: 0, + categories: 0, + accounts: 0, + transactions: 0, + }, accounts: [], }; @@ -246,7 +255,12 @@ export async function runSeed({ if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`); if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`); - // 6) Geliştirme hesapları tablosu + // 6) Son 6 ayın örnek işlemleri (faker.seed(42), idempotent) + const userIdMap = new Map([...ids.entries()]); + summary.created.transactions = await seedTransactions(householdIds, userIdMap); + if (summary.created.transactions) log(`✓ Örnek işlemler: ${summary.created.transactions}`); + + // 7) Geliştirme hesapları tablosu const all = [ { username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const }, ...SEED_USERS, diff --git a/scripts/seed/transactions.ts b/scripts/seed/transactions.ts new file mode 100644 index 0000000..4b22ee7 --- /dev/null +++ b/scripts/seed/transactions.ts @@ -0,0 +1,496 @@ +import { faker } from "@faker-js/faker"; +import { Types } from "mongoose"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { PayeeModel } from "@/models/payee"; +import { TransactionModel } from "@/models/transaction"; +import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; +import { allocate } from "@/shared/money"; +import { foldTr } from "@/shared/search"; +import type { TransactionType } from "@/shared/transactions"; + +/** + * Son 6 ayın gerçekçi işlemleri (faker.seed(42) ile tekrarlanabilir). + * Her işlemin sabit `seedRef`'i vardır: tekrar çalıştırmada kopya oluşmaz. + */ + +type Ctx = { + householdId: Types.ObjectId; + account: (name: string) => Types.ObjectId; + category: (key: string) => Types.ObjectId; + user: (username: string) => Types.ObjectId; +}; + +type SeedTx = { + ref: string; + type: TransactionType; + ymd: string; + amountMinor: number; + account: string; + toAccount?: string; + category?: string; + splits?: { category: string; weight: number; note?: string }[]; + payee?: string; + description?: string; + tags?: string[]; + by: string; +}; + +const tl = (min: number, max: number) => faker.number.int({ min: min * 100, max: max * 100 }); +const pick = (xs: readonly T[]) => faker.helpers.arrayElement(xs); + +/** Ayın gün sayısına göre güvenli gün (31 → ayın son günü). */ +function ymdOf(year: number, month: number, day: number) { + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return `${year}-${String(month).padStart(2, "0")}-${String(Math.min(day, last)).padStart(2, "0")}`; +} + +function months(count: number): { y: number; m: number; key: string }[] { + const [ty, tm] = todayIstanbul().split("-").map(Number) as [number, number]; + return Array.from({ length: count }, (_, i) => { + const d = new Date(Date.UTC(ty, tm - 1 - (count - 1 - i), 1)); + const y = d.getUTCFullYear(); + const m = d.getUTCMonth() + 1; + return { y, m, key: `${y}-${String(m).padStart(2, "0")}` }; + }); +} + +function cakirAiles(): SeedTx[] { + const out: SeedTx[] = []; + const marketPayees = ["Migros", "BİM", "A101", "Şok", "CarrefourSA"] as const; + for (const { y, m, key } of months(6)) { + const winter = [11, 12, 1, 2, 3].includes(m); + const d = (day: number) => ymdOf(y, m, day); + const add = (x: Omit, n = 0) => + out.push({ ...x, ref: `cakir:${key}:${x.payee ?? x.category ?? x.type}:${n}` }); + + add({ + type: "income", + ymd: d(15), + amountMinor: 52_000_00, + account: "Ziraat Maaş", + category: "maas", + payee: "Anadolu Yazılım A.Ş.", + by: "admin", + }); + add({ + type: "income", + ymd: d(1), + amountMinor: 4_500_00, + account: "Ayşe — Pluxee", + category: "yemek-karti", + payee: "Pluxee", + by: "ayse", + }); + add({ + type: "expense", + ymd: d(3), + amountMinor: 17_500_00, + account: "Ziraat Maaş", + category: "kira", + payee: "Ev sahibi", + description: "Kira", + by: "admin", + }); + add({ + type: "expense", + ymd: d(5), + amountMinor: 1_250_00, + account: "Ziraat Maaş", + category: "aidat", + payee: "Site yönetimi", + by: "admin", + }); + add({ + type: "expense", + ymd: d(10), + amountMinor: tl(750, 1_600), + account: "Ziraat Maaş", + category: "faturalar.elektrik", + payee: "Enerjisa", + by: "ayse", + }); + add({ + type: "expense", + ymd: d(12), + amountMinor: tl(300, 620), + account: "Ziraat Maaş", + category: "faturalar.su", + payee: "İSKİ", + by: "ayse", + }); + add({ + type: "expense", + ymd: d(14), + amountMinor: winter ? tl(1_800, 3_400) : tl(350, 700), + account: "Ziraat Maaş", + category: "faturalar.dogalgaz", + payee: "İGDAŞ", + by: "ayse", + }); + add({ + type: "expense", + ymd: d(20), + amountMinor: 499_90, + account: "Garanti Bonus", + category: "faturalar.internet", + payee: "Türk Telekom", + by: "admin", + }); + add({ + type: "expense", + ymd: d(22), + amountMinor: 714_00, + account: "Garanti Bonus", + category: "faturalar.telefon", + payee: "Turkcell", + description: "2 hat", + by: "admin", + }); + add({ + type: "expense", + ymd: d(8), + amountMinor: 229_99, + account: "Garanti Bonus", + category: "abonelikler", + payee: "Netflix", + by: "admin", + }); + add({ + type: "expense", + ymd: d(9), + amountMinor: 99_99, + account: "Garanti Bonus", + category: "abonelikler", + payee: "Spotify", + by: "emre", + }); + + // Transferler: nakit çekme, kart ödemesi, birikim. + add({ + type: "transfer", + ymd: d(2), + amountMinor: 2_000_00, + account: "Ziraat Maaş", + toAccount: "Cüzdan", + description: "ATM", + by: "admin", + }); + add({ + type: "transfer", + ymd: d(9), + amountMinor: tl(11_000, 15_000), + account: "Ziraat Maaş", + toAccount: "Garanti Bonus", + description: "Kart borcu ödemesi", + by: "admin", + }); + add({ + type: "transfer", + ymd: d(16), + amountMinor: 5_000_00, + account: "Ziraat Maaş", + toAccount: "Birikim (Vadeli)", + description: "Aylık birikim", + by: "ayse", + }); + + // Market: haftada 1–2; bir kısmı bölünmüş. + const marketCount = faker.number.int({ min: 6, max: 8 }); + for (let i = 0; i < marketCount; i++) { + const payee = pick(marketPayees); + const amountMinor = tl(280, 2_600); + const split = amountMinor > 1_200_00 && faker.datatype.boolean({ probability: 0.5 }); + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor, + account: pick(["Garanti Bonus", "Garanti Bonus", "Cüzdan"]), + ...(split + ? { + splits: [ + { category: "market.gida", weight: faker.number.int({ min: 55, max: 75 }) }, + { + category: "market.temizlik", + weight: faker.number.int({ min: 10, max: 25 }), + note: "Deterjan", + }, + { + category: "market.kisisel-bakim", + weight: faker.number.int({ min: 5, max: 15 }), + }, + ], + } + : { category: "market.gida" }), + payee, + tags: faker.datatype.boolean({ probability: 0.15 }) ? ["indirim"] : [], + by: pick(["ayse", "ayse", "admin", "emre"]), + }, + i, + ); + } + for (let i = 0; i < faker.number.int({ min: 2, max: 3 }); i++) { + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(1_200, 2_300), + account: "Garanti Bonus", + category: "ulasim.yakit", + payee: pick(["Shell", "Opet", "BP"]), + by: "admin", + }, + i, + ); + } + for (let i = 0; i < faker.number.int({ min: 4, max: 6 }); i++) { + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(110, 340), + account: "Ayşe — Pluxee", + category: "eglence.kafe", + payee: pick(["Starbucks", "Kahve Dünyası", "Simit Sarayı"]), + by: "ayse", + }, + i, + ); + } + for (let i = 0; i < faker.number.int({ min: 2, max: 4 }); i++) { + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(450, 1_600), + account: pick(["Garanti Bonus", "Ayşe — Pluxee"]), + category: "eglence.disarida-yemek", + payee: pick(["Köfteci Yusuf", "Nusr-Et", "Big Chefs", "Yemeksepeti"]), + by: pick(["ayse", "admin"]), + }, + i, + ); + } + for (let i = 0; i < 4; i++) { + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(60, 220), + account: "Cüzdan", + category: "ulasim.toplu-tasima", + payee: "İstanbulkart", + by: "emre", + }, + i, + ); + } + if (faker.datatype.boolean({ probability: 0.6 })) { + add({ + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(140, 650), + account: "Cüzdan", + category: "saglik.ilac", + payee: "Eczane", + by: "ayse", + }); + } + if (faker.datatype.boolean({ probability: 0.5 })) { + add({ + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(600, 3_200), + account: "Garanti Bonus", + category: "giyim", + payee: pick(["LC Waikiki", "Trendyol", "Koton"]), + by: "ayse", + }); + } + } + return out; +} + +function annemler(): SeedTx[] { + const out: SeedTx[] = []; + for (const { y, m, key } of months(6)) { + const winter = [11, 12, 1, 2, 3].includes(m); + const d = (day: number) => ymdOf(y, m, day); + const add = (x: Omit, n = 0) => + out.push({ ...x, ref: `annemler:${key}:${x.payee ?? x.category ?? x.type}:${n}` }); + add({ + type: "income", + ymd: d(17), + amountMinor: 24_350_00, + account: "Vakıfbank Emekli", + category: "maas", + payee: "SGK", + description: "Emekli maaşı", + by: "fatma", + }); + add({ + type: "expense", + ymd: d(11), + amountMinor: tl(450, 900), + account: "Vakıfbank Emekli", + category: "faturalar.elektrik", + payee: "Enerjisa", + by: "mehmet", + }); + add({ + type: "expense", + ymd: d(13), + amountMinor: winter ? tl(1_400, 2_600) : tl(250, 500), + account: "Vakıfbank Emekli", + category: "faturalar.dogalgaz", + payee: "İGDAŞ", + by: "mehmet", + }); + add({ + type: "transfer", + ymd: d(18), + amountMinor: 4_000_00, + account: "Vakıfbank Emekli", + toAccount: "Nakit", + description: "ATM", + by: "fatma", + }); + for (let i = 0; i < 5; i++) { + add( + { + type: "expense", + ymd: d(faker.number.int({ min: 1, max: 28 })), + amountMinor: tl(200, 1_100), + account: pick(["Nakit", "Vakıfbank Emekli"]), + category: "market.gida", + payee: pick(["BİM", "Şok", "Pazar"]), + by: pick(["fatma", "mehmet"]), + }, + i, + ); + } + } + return out; +} + +export async function seedTransactions( + householdIds: Map, + userIds: Map, +): Promise { + faker.seed(42); + let inserted = 0; + const plan: [string, SeedTx[]][] = [ + ["Çakır Ailesi", cakirAiles()], + ["Annemler", annemler()], + ]; + const today = todayIstanbul(); + + for (const [householdName, txs] of plan) { + const householdId = householdIds.get(householdName)!; + const [accounts, categories] = await Promise.all([ + AccountModel.find({ householdId }).select({ name: 1 }).lean(), + CategoryModel.find({ householdId, defaultKey: { $ne: null } }) + .select({ defaultKey: 1 }) + .lean(), + ]); + const ctx: Ctx = { + householdId, + account: (name) => accounts.find((a) => a.name === name)!._id, + category: (key) => categories.find((c) => c.defaultKey === key)!._id, + user: (username) => new Types.ObjectId(userIds.get(username)!), + }; + const payeeStats = new Map< + string, + { + name: string; + type: TransactionType; + account: Types.ObjectId; + category: Types.ObjectId | null; + count: number; + last: Date; + } + >(); + + for (const x of txs) { + if (x.ymd > today) continue; // Gelecek tarihli seed işlemi yok. + const date = istanbulDayStart(x.ymd)!; + const splits = x.splits + ? allocate( + x.amountMinor, + x.splits.map((s) => s.weight), + ).map((amountMinor, i) => ({ + categoryId: ctx.category(x.splits![i]!.category), + amountMinor, + note: x.splits![i]!.note ?? "", + })) + : []; + const categoryId = x.category ? ctx.category(x.category) : null; + const accountId = ctx.account(x.account); + const by = ctx.user(x.by); + const doc = { + householdId, + type: x.type, + status: "posted", + date, + accountId, + toAccountId: x.toAccount ? ctx.account(x.toAccount) : null, + amountMinor: x.amountMinor, + categoryId: splits.length ? null : categoryId, + splits, + effectiveCategoryIds: splits.length + ? [...new Set(splits.map((s) => String(s.categoryId)))].map( + (id) => new Types.ObjectId(id), + ) + : categoryId + ? [categoryId] + : [], + payeeName: x.payee ?? "", + description: x.description ?? "", + searchText: foldTr( + [x.payee ?? "", x.description ?? "", ...splits.map((s) => s.note)].join(" "), + ), + tags: x.tags ?? [], + seedRef: x.ref, + createdBy: by, + updatedBy: by, + }; + const res = await TransactionModel.updateOne( + { householdId, seedRef: x.ref }, + { $setOnInsert: doc }, + { upsert: true }, + ); + if (res.upsertedCount) inserted++; + if (x.payee) { + const key = foldTr(x.payee); + const prev = payeeStats.get(key); + const latest = !prev || date >= prev.last; + payeeStats.set(key, { + name: x.payee, + type: latest ? x.type : prev!.type, + account: latest ? accountId : prev!.account, + category: latest ? (splits.length ? null : categoryId) : prev!.category, + count: (prev?.count ?? 0) + 1, + last: latest ? date : prev!.last, + }); + } + } + for (const [nameKey, p] of payeeStats) { + await PayeeModel.updateOne( + { householdId, nameKey }, + { + $setOnInsert: { householdId, nameKey, name: p.name }, + $set: { + lastType: p.type, + lastAccountId: p.account, + lastCategoryId: p.category, + useCount: p.count, + lastUsedAt: p.last, + }, + }, + { upsert: true }, + ); + } + } + return inserted; +} diff --git a/src/app/(app)/islemler/disa-aktar/route.ts b/src/app/(app)/islemler/disa-aktar/route.ts new file mode 100644 index 0000000..e61ecc9 --- /dev/null +++ b/src/app/(app)/islemler/disa-aktar/route.ts @@ -0,0 +1,44 @@ +import { exportTransactions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { getHouseholdContext } from "@/lib/dal"; +import { csvMoney, toCsv } from "@/shared/csv"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate } from "@/shared/format"; +import { transactionFilterSchema } from "@/shared/schemas/transaction"; + +/** GET /islemler/disa-aktar?…filtreler → CSV (listedeki filtrelerle, sayfalama yok). */ +export async function GET(request: Request) { + const ctx = await getHouseholdContext(); + if (!ctx?.household) return new Response(tr.common.unauthorized, { status: 401 }); + const params = Object.fromEntries(new URL(request.url).searchParams); + const filters = transactionFilterSchema.parse(params); + const rows = await exportTransactions(ctx.user.id, ctx.household.id, filters); + if (!rows) return new Response(tr.common.unauthorized, { status: 403 }); + + const t = tr.transactions; + const csv = toCsv( + t.csv.header, + rows.map((r) => [ + r.no, + formatDate(`${r.date}T12:00:00+03:00`), + t.types[r.type], + t.statuses[r.status], + r.account, + r.toAccount, + r.category, + r.payee, + r.description, + r.note, + r.tags, + csvMoney(r.amountMinor), + r.createdBy, + ]), + ); + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${t.csv.filename(todayIstanbul())}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/(app)/islemler/page.tsx b/src/app/(app)/islemler/page.tsx new file mode 100644 index 0000000..bf6662c --- /dev/null +++ b/src/app/(app)/islemler/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { listMemberOptions } from "@/features/accounts/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { TransactionsView } from "@/features/transactions/components/transactions-view"; +import { getTransactionFormOptions, listTransactions } from "@/features/transactions/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { transactionFilterSchema } from "@/shared/schemas/transaction"; + +export const metadata: Metadata = { title: tr.transactions.title }; + +export default async function TransactionsPage({ searchParams }: PageProps<"/islemler">) { + const { user, household } = await requireActiveHousehold("viewer"); + const params = await searchParams; + // Yalnızca tekil string değerler (dizi parametreleri yok sayılır). + const raw = Object.fromEntries( + Object.entries(params).filter( + (e): e is [string, string] => typeof e[1] === "string" && e[1] !== "", + ), + ); + const filters = transactionFilterSchema.parse(raw); + const [data, options, members] = await Promise.all([ + listTransactions(user.id, household.id, filters), + getTransactionFormOptions(user.id, household.id), + listMemberOptions(user.id, household.id), + ]); + if (!data || !options || !members) notFound(); + return ( + <> + + + + ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 812d6d2..82d1583 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -56,7 +56,7 @@ export function BottomNav({ canWrite }: { canWrite: boolean }) {
- ))} -
- + !open && setMode(null)} /> ); } @@ -94,7 +43,7 @@ export function QuickAddProvider({ children }: { children: React.ReactNode }) { export function QuickAddButton({ className }: { className?: string }) { const { openQuickAdd } = useQuickAdd(); return ( - 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/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx new file mode 100644 index 0000000..be97413 --- /dev/null +++ b/src/features/transactions/components/transaction-form.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, Loader2, Plus, Split, Trash2, Wand2 } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Controller, useFieldArray, useForm, useWatch, type Resolver } from "react-hook-form"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoney } from "@/shared/format"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; +import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; +import { + createTransactionAction, + getTransactionFormOptionsAction, + searchPayeesAction, + updateTransactionAction, +} from "../actions"; +import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from "../service"; + +const t = tr.transactions; +const f = t.fields; + +export type TransactionFormMode = + | { kind: "create"; type: TransactionType } + | { kind: "edit"; transaction: TransactionDetail } + | { kind: "copy"; transaction: TransactionDetail }; + +type SplitValue = { categoryId: string; amount: string; note: string }; +type FormValues = { + type: TransactionType; + amount: string; + date: string; + accountId: string; + toAccountId: string; + categoryId: string; + splits: SplitValue[]; + payee: string; + description: string; + tags: string; +}; + +function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues { + if (mode.kind === "create") { + return { + type: mode.type, + amount: "", + date: todayIstanbul(), + accountId: options?.accounts[0]?.id ?? "", + toAccountId: "", + categoryId: "", + splits: [], + payee: "", + description: "", + tags: "", + }; + } + const x = mode.transaction; + return { + type: x.type, + amount: formatMoneyInput(x.amountMinor), + date: mode.kind === "copy" ? todayIstanbul() : x.date, + accountId: x.account.id, + toAccountId: x.toAccount?.id ?? "", + categoryId: x.categoryId ?? "", + splits: x.splits.map((s) => ({ + categoryId: s.categoryId, + amount: formatMoneyInput(s.amountMinor), + note: s.note, + })), + payee: x.payeeName, + description: x.description, + tags: x.tags.join(", "), + }; +} + +function AccountSelect({ + id, + value, + onChange, + accounts, + invalid, + exclude, +}: { + id: string; + value: string; + onChange: (v: string) => void; + accounts: { + id: string; + name: string; + icon?: TransactionFormOptions["accounts"][number]["icon"]; + color?: TransactionFormOptions["accounts"][number]["color"]; + }[]; + invalid?: boolean; + exclude?: string; +}) { + const current = accounts.find((a) => a.id === value); + return ( + + ); +} + +function CategorySelect({ + id, + value, + onChange, + options, + appearance, + invalid, + className, +}: { + id: string; + value: string; + onChange: (v: string) => void; + options: TransactionFormOptions["categories"]; + appearance: TransactionFormOptions["categoryAppearance"]; + invalid?: boolean; + className?: string; +}) { + const current = options.find((o) => o.id === value); + return ( + + ); +} + +/** Satıcı alanı: yazdıkça öneri; seçilince son kullanılan kategori/hesap önerilir. */ +function PayeeInput({ + value, + onChange, + onPick, +}: { + value: string; + onChange: (v: string) => void; + onPick: (p: PayeeSuggestion) => void; +}) { + const [items, setItems] = useState([]); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const timer = useRef | null>(null); + + function query(v: string) { + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(async () => { + const res = await searchPayeesAction(v); + setItems( + res.filter( + (p) => + p.name.toLocaleLowerCase("tr-TR") !== v.trim().toLocaleLowerCase("tr-TR") || + res.length > 1, + ), + ); + setActive(-1); + }, 150); + } + + function pick(p: PayeeSuggestion) { + onChange(p.name); + onPick(p); + setOpen(false); + } + + return ( +
    + 0} + aria-controls="tx-payee-list" + aria-autocomplete="list" + onChange={(e) => { + onChange(e.target.value); + setOpen(true); + query(e.target.value); + }} + onFocus={() => { + setOpen(true); + query(value); + }} + onBlur={() => setTimeout(() => setOpen(false), 150)} + onKeyDown={(e) => { + if (!open || !items.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && active >= 0) { + e.preventDefault(); + pick(items[active]!); + } else if (e.key === "Escape") setOpen(false); + }} + /> + {open && items.length > 0 ? ( +
      + {items.map((p, i) => ( +
    • { + e.preventDefault(); + pick(p); + }} + className="cursor-pointer rounded-md px-2 py-1.5 text-sm hover:bg-accent aria-selected:bg-accent" + > + {p.name} +
    • + ))} +
    + ) : null} +
    + ); +} + +export function TransactionFormDialog({ + mode, + onOpenChange, +}: { + mode: TransactionFormMode | null; + onOpenChange: (open: boolean) => void; +}) { + const [options, setOptions] = useState(null); + const [loadError, setLoadError] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [suggested, setSuggested] = useState(false); + const form = useForm({ + // FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir). + resolver: zodResolver(transactionFormSchema) as unknown as Resolver< + FormValues, + unknown, + TransactionForm + >, + defaultValues: toValues({ kind: "create", type: "expense" }, null), + }); + const splits = useFieldArray({ control: form.control, name: "splits" }); + + // Form her açıldığında seçenekleri sunucudan al (hesaplar/kategoriler güncel olsun). + useEffect(() => { + if (!mode) return; + let cancelled = false; + getTransactionFormOptionsAction().then((res) => { + if (cancelled) return; + if (!res.ok) { + setLoadError(res.error); + return; + } + setLoadError(null); + setOptions(res.data); + form.reset(toValues(mode, res.data)); + }); + return () => { + cancelled = true; + }; + }, [mode, form]); + + const type = useWatch({ control: form.control, name: "type" }); + const amount = useWatch({ control: form.control, name: "amount" }); + const accountId = useWatch({ control: form.control, name: "accountId" }); + const splitValues = useWatch({ control: form.control, name: "splits" }); + const splitMode = splits.fields.length > 0; + + // Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın). + const accounts = useMemo(() => { + const list = [...(options?.accounts ?? [])]; + if (mode && mode.kind !== "create") { + for (const a of [mode.transaction.account, mode.transaction.toAccount]) { + if (a && !list.some((x) => x.id === a.id)) + list.push({ ...a, type: "bank", icon: "wallet", color: "slate" }); + } + } + return list; + }, [options, mode]); + const categories = useMemo(() => { + const kind = type === "income" ? "income" : "expense"; + const list = (options?.categories ?? []).filter((c) => c.kind === kind); + if (mode && mode.kind !== "create") { + const extra = [ + ...(mode.transaction.category + ? [{ id: mode.transaction.category.id, path: mode.transaction.category.path }] + : []), + ...mode.transaction.splits.map((s) => ({ id: s.categoryId, path: s.path })), + ]; + for (const c of extra) { + if (!list.some((x) => x.id === c.id)) + list.push({ id: c.id, label: c.path, path: c.path, depth: 0, kind }); + } + } + return list; + }, [options, type, mode]); + + const totalMinor = parseMoney(amount ?? "") ?? 0; + const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0); + const remaining = totalMinor - splitSum; + + function handleOpenChange(open: boolean) { + if (!open) { + setAdvancedOpen(false); + setSuggested(false); + } + onOpenChange(open); + } + + function changeType(next: TransactionType) { + form.setValue("type", next); + // Kategoriler türe bağlıdır; tür değişince temizlenir. + form.setValue("categoryId", ""); + splits.replace([]); + if (next !== "transfer") form.setValue("toAccountId", ""); + form.clearErrors(); + } + + function toggleSplit() { + if (splitMode) { + splits.replace([]); + return; + } + const first = form.getValues("categoryId"); + splits.replace([ + { categoryId: first, amount: form.getValues("amount"), note: "" }, + { categoryId: "", amount: "", note: "" }, + ]); + form.setValue("categoryId", ""); + setAdvancedOpen(true); + } + + function fillRemaining() { + const last = splits.fields.length - 1; + if (last < 0) return; + const current = parseMoney(form.getValues(`splits.${last}.amount`) ?? "") ?? 0; + const next = current + remaining; + if (next > 0) + form.setValue(`splits.${last}.amount`, formatMoneyInput(next), { shouldValidate: true }); + } + + function onPayeePicked(p: PayeeSuggestion) { + let changed = false; + // Kullanıcı seçimini ezme: yalnızca boş alanları doldur. + if (p.lastAccountId && !form.getValues("accountId")) { + form.setValue("accountId", p.lastAccountId); + changed = true; + } else if (p.lastAccountId && form.formState.dirtyFields.accountId === undefined) { + form.setValue("accountId", p.lastAccountId); + changed = true; + } + if ( + p.lastCategoryId && + !splitMode && + !form.getValues("categoryId") && + p.lastType === form.getValues("type") + ) { + form.setValue("categoryId", p.lastCategoryId); + changed = true; + } + setSuggested(changed); + } + + async function onSubmit() { + if (!mode) return; + const values = form.getValues(); + const payload = { + ...values, + toAccountId: values.type === "transfer" ? values.toAccountId : "", + categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId, + splits: values.type === "transfer" ? [] : values.splits, + }; + const result = + mode.kind === "edit" + ? await updateTransactionAction(mode.transaction.id, payload) + : await createTransactionAction(payload); + if (result.ok) { + toast.success(mode.kind === "edit" ? t.toast.updated : t.toast.created); + handleOpenChange(false); + return; + } + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (!message) continue; + form.setError(name as keyof FormValues, { message }); + if (name.startsWith("splits") || name === "tags") setAdvancedOpen(true); + } + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + const title = + mode?.kind === "edit" + ? t.editTitle + : mode?.kind === "copy" + ? t.copyTitle + : t.createTitle[mode?.type ?? "expense"]; + const loading = Boolean(mode) && !options && !loadError; + const noAccounts = options && options.accounts.length === 0; + const errors = form.formState.errors; + + return ( + + + + + ) + } + > + {loadError ? ( +

    {loadError}

    + ) : loading ? ( +
    + + + + +
    + ) : noAccounts ? ( +
    +

    {t.noAccounts}

    + +
    + ) : ( +
    + + {mode?.kind !== "edit" ? ( + changeType(v as TransactionType)}> + + {TRANSACTION_TYPES.map((x) => ( + + {t.types[x]} + + ))} + + + ) : null} + +
    + ( + + {f.amount} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {f.date} + + {fieldState.invalid ? : null} + + )} + /> +
    + +
    + ( + + + {type === "transfer" ? f.fromAccount : f.account} + + + {fieldState.invalid ? : null} + + )} + /> + {type === "transfer" ? ( + ( + + {f.toAccount} + + {fieldState.invalid ? : null} + + )} + /> + ) : null} +
    + + {type !== "transfer" && !splitMode ? ( + ( + + {f.category} +
    + + +
    + {fieldState.invalid ? : null} +
    + )} + /> + ) : null} + + ( + + {f.payee} + + {suggested ? {f.suggested} : null} + + )} + /> + ( + + {f.description} + + {fieldState.invalid ? : null} + + )} + /> + + + + + + + + ( + + {f.tags} + + {f.tagsHint} + + )} + /> + + {type !== "transfer" ? ( +
    +
    +
    +

    {f.split}

    +

    {f.splitHint}

    +
    + +
    + {splits.fields.map((row, i) => ( +
    + ( +
    + + {fieldState.invalid ? ( + + ) : null} +
    + )} + /> + ( + + )} + /> + + ( + + )} + /> +
    + ))} + {splitMode ? ( +
    + + + {remaining === 0 + ? f.remainingOk + : `${f.remaining}: ${formatMoney(remaining)}`} + + {remaining !== 0 ? ( + + ) : null} +
    + ) : null} + {errors.splits?.message || errors.splits?.root?.message ? ( + + {errors.splits.message ?? errors.splits.root?.message} + + ) : null} +
    + ) : null} +
    +
    +
    + {errors.root ? {errors.root.message} : null} +
    +
    + )} +
    + ); +} diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx new file mode 100644 index 0000000..453fc78 --- /dev/null +++ b/src/features/transactions/components/transactions-view.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { + ArrowLeftRight, + ChevronLeft, + ChevronRight, + Copy, + Download, + Filter, + Paperclip, + Pencil, + Plus, + Split, + Trash2, + X, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { useQuickAdd } from "@/components/layout/quick-add"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatDate, formatDateTime, formatMoney } from "@/shared/format"; +import type { TransactionFilters } from "@/shared/schemas/transaction"; +import { + TRANSACTION_STATUSES, + TRANSACTION_TYPES, + type TransactionType, +} from "@/shared/transactions"; +import { getTransactionAction, setTransactionDeletedAction } from "../actions"; +import type { + TransactionDetail, + TransactionFormOptions, + TransactionListItem, + TransactionPage, +} from "../service"; + +const t = tr.transactions; +const ALL = "__all__"; +const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" }); + +/** "2026-11-05" → "05.11.2026 · Perşembe" */ +function dayLabel(ymd: string) { + const d = new Date(`${ymd}T12:00:00+03:00`); + return `${formatDate(d)} · ${weekday.format(d)}`; +} + +function signed(type: TransactionType, amountMinor: number) { + return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0; +} + +function Amount({ + type, + amountMinor, + className, +}: { + type: TransactionType; + amountMinor: number; + className?: string; +}) { + const text = + type === "income" + ? `+${formatMoney(amountMinor)}` + : type === "expense" + ? `−${formatMoney(amountMinor)}` + : formatMoney(amountMinor); + return ( + + {text} + + ); +} + +function RowIcon({ item }: { item: TransactionListItem }) { + if (item.type === "transfer") { + return ( + + + + ); + } + if (item.splitCount) { + return ( + + + + ); + } + return item.category ? ( + + ) : ( + + ); +} + +function rowTitle(item: TransactionListItem) { + if (item.payeeName) return item.payeeName; + if (item.description) return item.description; + if (item.type === "transfer") return t.types.transfer; + return item.category?.name ?? t.types[item.type]; +} + +function rowSubtitle(item: TransactionListItem) { + if (item.type === "transfer") + return t.list.transfer(item.account.name, item.toAccount?.name ?? "—"); + const what = item.splitCount ? t.list.split(item.splitCount) : (item.category?.path ?? "—"); + return `${what} · ${item.account.name}`; +} + +// --- Filtreler --- + +type Options = TransactionFormOptions & { members: { id: string; name: string }[] }; + +function countActive(f: TransactionFilters) { + return ( + [ + "from", + "to", + "type", + "hesap", + "kategori", + "kisi", + "durum", + "min", + "max", + "etiket", + "q", + ] as const + ).filter((k) => f[k] !== undefined && f[k] !== "").length; +} + +function FiltersDialog({ + open, + onOpenChange, + filters, + raw, + options, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + filters: TransactionFilters; + raw: Record; + options: Options; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [v, setV] = useState>(raw); + const set = (k: string, value: string) => setV((prev) => ({ ...prev, [k]: value })); + const f = t.filters; + + function apply(next: Record) { + const params = new URLSearchParams(); + for (const [k, value] of Object.entries(next)) if (value && k !== "sayfa") params.set(k, value); + router.push(params.size ? `${pathname}?${params}` : pathname); + onOpenChange(false); + } + + const select = (key: string, label: string, items: { value: string; label: string }[]) => ( + + {label} + + + ); + + return ( + { + if (o) setV(raw); + onOpenChange(o); + }} + title={f.title} + footer={ + <> + + + + } + > +
    { + e.preventDefault(); + apply(v); + }} + > + + + {f.search} + set("q", e.target.value)} + placeholder={f.searchPlaceholder} + /> + +
    + + {f.from} + set("from", e.target.value)} + /> + + + {f.to} + set("to", e.target.value)} + /> + +
    +
    + {select( + "type", + f.type, + TRANSACTION_TYPES.map((x) => ({ value: x, label: t.types[x] })), + )} + {select( + "durum", + f.status, + TRANSACTION_STATUSES.map((x) => ({ value: x, label: t.statuses[x] })), + )} +
    + {select( + "hesap", + f.account, + options.accounts.map((a) => ({ value: a.id, label: a.name })), + )} + {select( + "kategori", + f.category, + options.categories.map((c) => ({ + value: c.id, + label: `${c.kind === "income" ? "↑" : "↓"} ${c.path}`, + })), + )} + {select( + "kisi", + f.person, + options.members.map((m) => ({ value: m.id, label: m.name })), + )} +
    + + {f.min} + set("min", x)} /> + + + {f.max} + set("max", x)} /> + +
    + + {f.tag} + set("etiket", e.target.value)} + /> + + + + + + ) : null + } + > + {!detail ? ( +
    + + +
    + ) : ( +
    +
    + +
    +

    {rowTitle(detail)}

    +

    {dayLabel(detail.date)}

    +
    + +
    +
    + {row(t.filters.type, {t.types[detail.type]})} + {detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null} + {row( + detail.type === "transfer" ? t.fields.fromAccount : t.fields.account, + detail.account.name, + )} + {detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null} + {detail.category ? row(t.fields.category, detail.category.path) : null} + {detail.payeeName ? row(t.fields.payee, detail.payeeName) : null} + {detail.description ? row(t.fields.description, detail.description) : null} + {detail.tags.length + ? row( + t.fields.tags, + + {detail.tags.map((tag) => ( + + #{tag} + + ))} + , + ) + : null} + {row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)} + {new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000 + ? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`) + : null} +
    + {detail.splits.length ? ( +
    +

    + {d.lines} +

    +
      + {detail.splits.map((s, i) => ( +
    • + + + {s.path} + {s.note ? ( + + {s.note} + + ) : null} + + {formatMoney(s.amountMinor)} +
    • + ))} +
    +
    + ) : null} +
    + )} +
    + ); +} + +// --- Ana görünüm --- + +export function TransactionsView({ + data, + filters, + raw, + options, + canWrite, +}: { + data: TransactionPage; + filters: TransactionFilters; + raw: Record; + options: Options; + canWrite: boolean; +}) { + const pathname = usePathname(); + const { openQuickAdd, openTransactionForm } = useQuickAdd(); + const [filtersOpen, setFiltersOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [deleting, setDeleting] = useState(null); + const [, startTransition] = useTransition(); + const active = countActive(filters); + + const pageHref = (page: number) => { + const params = new URLSearchParams(raw); + if (page > 1) params.set("sayfa", String(page)); + else params.delete("sayfa"); + return params.size ? `${pathname}?${params}` : pathname; + }; + const exportHref = () => { + const params = new URLSearchParams(raw); + params.delete("sayfa"); + return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`; + }; + + async function openDetail(id: string) { + setDetailLoading(true); + setDetail(null); + const res = await getTransactionAction(id); + setDetailLoading(false); + if (res.ok) setDetail(res.data); + else toast.error(res.error); + } + + function closeDetail() { + setDetail(null); + setDetailLoading(false); + } + + function remove(d: TransactionDetail) { + setDeleting(null); + closeDetail(); + startTransition(async () => { + const res = await setTransactionDeletedAction(d.id, true); + if (!res.ok) return void toast.error(res.error); + toast.success(t.toast.deleted, { + duration: 10_000, + action: { + label: t.actions.restore, + onClick: async () => { + const r = await setTransactionDeletedAction(d.id, false); + if (r.ok) toast.success(t.toast.restored); + else toast.error(r.error); + }, + }, + }); + }); + } + + // Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı). + const groups: { date: string; items: TransactionListItem[] }[] = []; + for (const item of data.items) { + const last = groups.at(-1); + if (last?.date === item.date) last.items.push(item); + else groups.push({ date: item.date, items: [item] }); + } + + return ( +
    +
    + + {active ? ( + + ) : null} + + {canWrite ? ( + + ) : null} +
    + +
    + {( + [ + ["income", data.sums.incomeMinor, "text-income"], + ["expense", data.sums.expenseMinor, "text-expense"], + ["net", data.sums.incomeMinor - data.sums.expenseMinor, ""], + ] as const + ).map(([key, value, tone]) => ( + + + {t.list.sums[key]} + + {formatMoney(value)} + + + + ))} +
    + + {data.items.length === 0 ? ( +

    + {active ? t.list.empty : t.list.emptyAll} +

    + ) : ( + groups.map((g) => { + const net = g.items.reduce( + (a, i) => a + (i.status === "posted" ? signed(i.type, i.amountMinor) : 0), + 0, + ); + return ( +
    +
    +

    {dayLabel(g.date)}

    + 0 && "text-income", + net < 0 && "text-expense", + )} + title={t.list.dayTotal} + > + {net === 0 ? "" : `${net > 0 ? "+" : "−"}${formatMoney(Math.abs(net))}`} + +
    +
      + {g.items.map((item) => ( +
    • + +
    • + ))} +
    +
    + ); + }) + )} + + {data.pageCount > 1 ? ( + + ) : data.total ? ( +

    {t.list.total(data.total)}

    + ) : null} + + + !o && closeDetail()} + onEdit={(d) => { + closeDetail(); + openTransactionForm({ kind: "edit", transaction: d }); + }} + onCopy={(d) => { + closeDetail(); + openTransactionForm({ kind: "copy", transaction: d }); + }} + onDelete={setDeleting} + /> + !o && setDeleting(null)}> + + + {t.deleteTitle} + {t.deleteDescription} + + + {tr.common.cancel} + + + + +
    + ); +} 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/i18n/tr.ts b/src/i18n/tr.ts index c2ad9e8..d172a56 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -233,6 +233,121 @@ export const tr = { typeLocked: "Hesap türü değiştirilemez.", ownerOnly: "Hesapları yalnızca hane sahipleri yönetebilir.", }, + transactions: { + title: "İşlemler", + description: "Gelir, gider ve transferler.", + new: "Yeni işlem", + types: { expense: "Gider", income: "Gelir", transfer: "Transfer" }, + statuses: { posted: "Gerçekleşti", planned: "Planlı", void: "İptal" }, + createTitle: { expense: "Yeni gider", income: "Yeni gelir", transfer: "Yeni transfer" }, + editTitle: "İşlemi düzenle", + copyTitle: "İşlemi kopyala", + fields: { + amount: "Tutar", + date: "Tarih", + account: "Hesap", + fromAccount: "Kaynak hesap", + toAccount: "Hedef hesap", + category: "Kategori", + selectCategory: "Kategori seçin", + selectAccount: "Hesap seçin", + payee: "Satıcı / karşı taraf", + payeePlaceholder: "ör. Migros", + description: "Açıklama", + tags: "Etiketler", + tagsHint: "Virgülle ayırın (ör. tatil, indirim)", + advanced: "Gelişmiş: etiket, bölme", + split: "Bölünmüş işlem", + splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.", + addSplit: "Kalem ekle", + removeSplit: "Kalemi kaldır", + splitNote: "Not", + remaining: "Kalan", + remainingOk: "Kalemler toplamı tutara eşit", + fillRemaining: "Kalanı son kaleme ekle", + suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.", + }, + validation: { + amountPositive: "Tutar sıfırdan büyük olmalı", + selectAccount: "Hesap seçin", + selectToAccount: "Hedef hesabı seçin", + sameAccount: "Kaynak ve hedef hesap aynı olamaz", + selectCategory: "Kategori seçin", + splitMin: "Bölünmüş işlemde en az 2 kalem olmalı", + splitSum: "Kalemlerin toplamı tutara eşit olmalı", + accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.", + categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.", + }, + list: { + empty: "Bu filtrelerle işlem bulunamadı.", + emptyAll: "Henüz işlem yok.", + split: (n: number) => `Bölünmüş · ${n} kalem`, + transfer: (from: string, to: string) => `${from} → ${to}`, + dayTotal: "Günlük net", + total: (n: number) => `${n} işlem`, + sums: { income: "Gelir", expense: "Gider", net: "Net" }, + hiddenAccount: "Gizli hesap", + }, + filters: { + title: "Filtreler", + open: "Filtrele", + apply: "Uygula", + clear: "Temizle", + from: "Başlangıç", + to: "Bitiş", + type: "Tür", + account: "Hesap", + category: "Kategori", + person: "Ekleyen", + status: "Durum", + min: "En az (TL)", + max: "En çok (TL)", + tag: "Etiket", + search: "Ara", + searchPlaceholder: "Açıklama, satıcı veya not…", + all: "Tümü", + active: (n: number) => `${n} filtre`, + }, + detail: { + title: "İşlem ayrıntısı", + lines: "Kalemler", + createdBy: "Ekleyen", + updatedBy: "Son düzenleyen", + createdAt: "Oluşturulma", + }, + actions: { edit: "Düzenle", copy: "Kopyala", delete: "Sil", restore: "Geri al" }, + toast: { + created: "İşlem kaydedildi.", + updated: "İşlem güncellendi.", + deleted: "İşlem silindi.", + restored: "İşlem geri alındı.", + }, + deleteTitle: "İşlem silinsin mi?", + deleteDescription: + "İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.", + export: "CSV indir", + csv: { + header: [ + "İşlem No", + "Tarih", + "Tür", + "Durum", + "Hesap", + "Hedef hesap", + "Kategori", + "Satıcı", + "Açıklama", + "Kalem notu", + "Etiketler", + "Tutar", + "Ekleyen", + ], + filename: (date: string) => `butcem-islemler-${date}.csv`, + }, + readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", + noAccounts: "İşlem girmek için önce bir hesap gerekli.", + noAccountsLink: "Hesaplara git", + }, categories: { title: "Kategoriler", description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", @@ -315,7 +430,7 @@ export const tr = { expenseHint: "Harcama, fatura, alışveriş", incomeHint: "Maaş, ek gelir, iade", transferHint: "Hesaplar ve cariler arası", - notYet: "İşlem formu Faz 6'da eklenecek.", + noAccounts: "İşlem girmek için önce bir hesap gerekli.", }, auth: { loginTitle: "Giriş yap", 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); diff --git a/src/shared/csv.ts b/src/shared/csv.ts new file mode 100644 index 0000000..2008b1c --- /dev/null +++ b/src/shared/csv.ts @@ -0,0 +1,29 @@ +/** + * CSV üretimi — Türkçe Excel ile uyumlu: noktalı virgül ayırıcı, ondalık virgül, + * UTF-8 BOM (Türkçe karakterler doğru açılır), CRLF satır sonu. + * Formül enjeksiyonuna karşı = + - @ ile başlayan metinler tek tırnakla etkisizleştirilir. + */ +export const CSV_SEPARATOR = ";"; +const BOM = ""; + +export type CsvValue = string | number | null | undefined; + +function escapeCell(value: CsvValue): string { + if (value === null || value === undefined) return ""; + let s = typeof value === "number" ? String(value) : value; + // Sayısal görünen değerler (ör. "-1250,50") olduğu gibi kalır; diğerleri formül olarak çalışmasın. + if (typeof value === "string" && /^[=+\-@\t\r]/.test(s) && !/^-?\d+(,\d+)?$/.test(s)) s = `'${s}`; + return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv(header: readonly string[], rows: readonly (readonly CsvValue[])[]): string { + const lines = [header, ...rows].map((r) => r.map(escapeCell).join(CSV_SEPARATOR)); + return BOM + lines.join("\r\n") + "\r\n"; +} + +/** Kuruş → "1250,50" (CSV'de sayı olarak okunur; binlik ayırıcı yok). */ +export function csvMoney(amountMinor: number): string { + const sign = amountMinor < 0 ? "-" : ""; + const abs = Math.abs(amountMinor); + return `${sign}${Math.trunc(abs / 100)},${String(abs % 100).padStart(2, "0")}`; +} diff --git a/src/shared/schemas/transaction.ts b/src/shared/schemas/transaction.ts new file mode 100644 index 0000000..e160eca --- /dev/null +++ b/src/shared/schemas/transaction.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { parseMoney } from "../money"; +import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions"; + +const v = tr.validation; +const t = tr.transactions.validation; +const objectId = z.string().regex(/^[a-f0-9]{24}$/i, t.selectAccount); +const optionalId = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .nullable() + .optional() + .transform((s) => (s ? s : null)); + +/** Pozitif tutar ("1.250,50" → 125050). */ +const positiveMoney = z + .string() + .trim() + .transform((s, ctx) => { + const minor = parseMoney(s); + if (minor === null || minor <= 0) { + ctx.addIssue({ code: "custom", message: minor === null ? v.money : t.amountPositive }); + return z.NEVER; + } + return minor; + }); + +const splitSchema = z.object({ + categoryId: objectId.refine(Boolean, t.selectCategory), + amount: positiveMoney, + note: z.string().trim().max(120).default(""), +}); + +/** + * İşlem formu (web + mobil). Kurallar: + * - transfer: hedef hesap zorunlu ve kaynaktan farklı; kategori/bölme yok. + * - gelir/gider: ya tek kategori ya da en az 2 kalemli bölme; kalemler toplamı = tutar. + */ +export const transactionFormSchema = z + .object({ + type: z.enum(TRANSACTION_TYPES), + amount: positiveMoney, + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date), + accountId: objectId, + toAccountId: optionalId, + categoryId: optionalId, + splits: z.array(splitSchema).max(MAX_SPLITS).default([]), + payee: z.string().trim().max(80).default(""), + description: z.string().trim().max(500).default(""), + tags: z + .union([z.string(), z.array(z.string())]) + .default("") + .transform((x) => normalizeTags(x)), + }) + .superRefine((x, ctx) => { + if (x.type === "transfer") { + if (!x.toAccountId) + ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.selectToAccount }); + else if (x.toAccountId === x.accountId) + ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount }); + return; + } + if (x.splits.length === 1) { + ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin }); + } else if (x.splits.length >= 2) { + const sum = x.splits.reduce((a, s) => a + s.amount, 0); + if (sum !== x.amount) ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitSum }); + } else if (!x.categoryId) { + ctx.addIssue({ code: "custom", path: ["categoryId"], message: t.selectCategory }); + } + }); + +export type TransactionFormInput = z.input; +export type TransactionForm = z.output; + +/** Liste filtreleri (URL arama parametrelerinden). Geçersiz değerler yok sayılır. */ +export const transactionFilterSchema = z.object({ + from: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .catch(undefined), + to: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .catch(undefined), + type: z.enum(TRANSACTION_TYPES).optional().catch(undefined), + hesap: z + .string() + .regex(/^[a-f0-9]{24}$/i) + .optional() + .catch(undefined), + kategori: z + .string() + .regex(/^[a-f0-9]{24}$/i) + .optional() + .catch(undefined), + kisi: z + .string() + .regex(/^[a-f0-9]{24}$/i) + .optional() + .catch(undefined), + durum: z.enum(["posted", "planned", "void"]).optional().catch(undefined), + min: z + .string() + .optional() + .catch(undefined) + .transform((s) => (s ? (parseMoney(s) ?? undefined) : undefined)), + max: z + .string() + .optional() + .catch(undefined) + .transform((s) => (s ? (parseMoney(s) ?? undefined) : undefined)), + etiket: z.string().trim().max(30).optional().catch(undefined), + q: z.string().trim().max(80).optional().catch(undefined), + sayfa: z.coerce.number().int().min(1).max(10_000).catch(1).default(1), +}); +export type TransactionFilters = z.output; diff --git a/src/shared/transactions.test.ts b/src/shared/transactions.test.ts new file mode 100644 index 0000000..71e0317 --- /dev/null +++ b/src/shared/transactions.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { toCsv, csvMoney } from "./csv"; +import { transactionFormSchema } from "./schemas/transaction"; +import { normalizeTags, transactionEffects } from "./transactions"; + +const A = "a".repeat(24); +const B = "b".repeat(24); +const C1 = "c".repeat(24); +const C2 = "d".repeat(24); + +describe("transactionEffects", () => { + it("gelir +, gider −, transfer kaynaktan − hedefe +", () => { + expect( + transactionEffects({ type: "income", status: "posted", amountMinor: 500, accountId: A }), + ).toEqual([{ accountId: A, deltaMinor: 500 }]); + expect( + transactionEffects({ type: "expense", status: "posted", amountMinor: 500, accountId: A }), + ).toEqual([{ accountId: A, deltaMinor: -500 }]); + expect( + transactionEffects({ + type: "transfer", + status: "posted", + amountMinor: 500, + accountId: A, + toAccountId: B, + }), + ).toEqual([ + { accountId: A, deltaMinor: -500 }, + { accountId: B, deltaMinor: 500 }, + ]); + }); + + it("planlı, iptal ve silinmiş işlemler etkisizdir", () => { + for (const x of [ + { status: "planned" as const }, + { status: "void" as const }, + { status: "posted" as const, deleted: true }, + ]) { + expect(transactionEffects({ type: "expense", amountMinor: 1, accountId: A, ...x })).toEqual( + [], + ); + } + }); +}); + +describe("normalizeTags", () => { + it("küçük harf, kırpma, tekrar yok, en fazla 10", () => { + expect(normalizeTags(" Market, İNDİRİM ,market,,#tatil")).toEqual([ + "market", + "indirim", + "tatil", + ]); + expect(normalizeTags(Array.from({ length: 15 }, (_, i) => `e${i}`))).toHaveLength(10); + }); +}); + +describe("transactionFormSchema", () => { + const base = { type: "expense", amount: "1.200,00", date: "2026-11-05", accountId: A }; + + it("tek kategorili gideri kabul eder ve tutarı kuruşa çevirir", () => { + const r = transactionFormSchema.parse({ ...base, categoryId: C1, tags: "ev, Ev" }); + expect(r.amount).toBe(120000); + expect(r.categoryId).toBe(C1); + expect(r.tags).toEqual(["ev"]); + expect(r.splits).toEqual([]); + }); + + it("kategorisiz ve bölmesiz gideri reddeder", () => { + expect(transactionFormSchema.safeParse(base).success).toBe(false); + }); + + it("bölünmüş işlemde kalem toplamı tutara eşit olmalı", () => { + const ok = transactionFormSchema.safeParse({ + ...base, + splits: [ + { categoryId: C1, amount: "800" }, + { categoryId: C2, amount: "250" }, + { categoryId: C1, amount: "150,00" }, + ], + }); + expect(ok.success).toBe(true); + const bad = transactionFormSchema.safeParse({ + ...base, + splits: [ + { categoryId: C1, amount: "800" }, + { categoryId: C2, amount: "250" }, + ], + }); + expect(bad.success).toBe(false); + expect(bad.error?.issues[0]?.path).toEqual(["splits"]); + const single = transactionFormSchema.safeParse({ + ...base, + splits: [{ categoryId: C1, amount: "1200" }], + }); + expect(single.success).toBe(false); + }); + + it("transfer: hedef zorunlu, kaynaktan farklı", () => { + expect(transactionFormSchema.safeParse({ ...base, type: "transfer" }).success).toBe(false); + expect( + transactionFormSchema.safeParse({ ...base, type: "transfer", toAccountId: A }).success, + ).toBe(false); + expect( + transactionFormSchema.safeParse({ ...base, type: "transfer", toAccountId: B }).success, + ).toBe(true); + }); + + it("sıfır, negatif ve geçersiz tutarı reddeder", () => { + for (const amount of ["0", "-5", "abc", ""]) { + expect( + transactionFormSchema.safeParse({ ...base, categoryId: C1, amount }).success, + amount, + ).toBe(false); + } + }); +}); + +describe("CSV", () => { + it("BOM, noktalı virgül, kaçış ve formül enjeksiyonu koruması", () => { + const csv = toCsv( + ["A", "B"], + [ + ["x;y", '"tırnak"'], + ["=HYPERLINK(1)", csvMoney(-125050)], + ], + ); + expect(csv.startsWith("")).toBe(true); + expect(csv).toContain('"x;y";"""tırnak"""'); + expect(csv).toContain("'=HYPERLINK(1);-1250,50"); + expect(csv.endsWith("\r\n")).toBe(true); + }); +}); diff --git a/src/shared/transactions.ts b/src/shared/transactions.ts new file mode 100644 index 0000000..a62e0ce --- /dev/null +++ b/src/shared/transactions.ts @@ -0,0 +1,53 @@ +/** İşlem türleri ve durumları (Bölüm 6.3). */ +export const TRANSACTION_TYPES = ["expense", "income", "transfer"] as const; +export type TransactionType = (typeof TRANSACTION_TYPES)[number]; + +/** planned: Faz 7 (planlı işlemler) · posted: gerçekleşti · void: iptal (bakiyeyi etkilemez). */ +export const TRANSACTION_STATUSES = ["planned", "posted", "void"] as const; +export type TransactionStatus = (typeof TRANSACTION_STATUSES)[number]; + +export const MAX_SPLITS = 20; +export const MAX_TAGS = 10; + +export type EffectInput = { + type: TransactionType; + status: TransactionStatus; + amountMinor: number; + accountId: string; + toAccountId?: string | null; + deleted?: boolean; +}; + +/** + * İşlemin hesap bakiyelerine etkisi (kuruş). Tutar her zaman pozitiftir; işaret türden gelir: + * gelir +, gider −, transfer kaynaktan −, hedefe +. Planlı, iptal ve silinmiş işlemler etkisizdir. + * Kredi kartında gider borcu artırır (bakiye daha negatif), karta transfer borcu azaltır. + */ +export function transactionEffects(t: EffectInput): { accountId: string; deltaMinor: number }[] { + if (t.status !== "posted" || t.deleted) return []; + switch (t.type) { + case "income": + return [{ accountId: t.accountId, deltaMinor: t.amountMinor }]; + case "expense": + return [{ accountId: t.accountId, deltaMinor: -t.amountMinor }]; + case "transfer": + return t.toAccountId + ? [ + { accountId: t.accountId, deltaMinor: -t.amountMinor }, + { accountId: t.toAccountId, deltaMinor: t.amountMinor }, + ] + : []; + } +} + +/** "market, İndirim ,market" → ["market", "indirim"] (küçük harf, tekrar yok, en fazla 10). */ +export function normalizeTags(input: string | readonly string[]): string[] { + const parts = typeof input === "string" ? input.split(/[,#]/) : input; + const out: string[] = []; + for (const raw of parts) { + const tag = raw.trim().replace(/\s+/g, " ").toLocaleLowerCase("tr-TR").slice(0, 30); + if (tag && !out.includes(tag)) out.push(tag); + if (out.length >= MAX_TAGS) break; + } + return out; +}