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; }