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