From ebcdf3f741edaec1695937a33eb64dabcf479a6a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 7 Nov 2026 09:31:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(import):=20Kakeibo=20=C3=A7al=C4=B1=C5=9Fm?= =?UTF-8?q?a=20kitab=C4=B1=20i=C3=A7in=20i=C3=A7e=20aktarma=20motoru=20ve?= =?UTF-8?q?=20servis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kredi Kartı / Maaş Hesabı sayfaları ayrıştırılır; ekstre toplamı ve bakiye zinciri kontrolü, taksit planı, iade = gelir, kart ödemesi / ATM / hesaplar arası = transfer, KMH ve ücretler = banka maliyeti - Taslak parti (importBatches), hesap rolleri ve önerilen hesaplar, satır düzenleme (aynı açıklamalılara toplu), tek transaction'da işleme, parmak iziyle tekrar engeli ve toplu geri alma - Migration 017-imports (geri alınabilir), denetim kayıtları Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/017-imports.ts | 19 + scripts/migrations/index.ts | 2 + src/features/imports/actions.ts | 126 ++++ src/features/imports/service.int.test.ts | 264 +++++++ src/features/imports/service.ts | 816 +++++++++++++++++++++ src/i18n/tr.ts | 140 ++++ src/models/audit-log.ts | 2 + src/models/import-batch.ts | 64 ++ src/models/transaction.ts | 12 + src/shared/import/kakeibo-workbook.test.ts | 277 +++++++ src/shared/import/kakeibo-workbook.ts | 473 ++++++++++++ 11 files changed, 2195 insertions(+) create mode 100644 scripts/migrations/017-imports.ts create mode 100644 src/features/imports/actions.ts create mode 100644 src/features/imports/service.int.test.ts create mode 100644 src/features/imports/service.ts create mode 100644 src/models/import-batch.ts create mode 100644 src/shared/import/kakeibo-workbook.test.ts create mode 100644 src/shared/import/kakeibo-workbook.ts diff --git a/scripts/migrations/017-imports.ts b/scripts/migrations/017-imports.ts new file mode 100644 index 0000000..1cf2169 --- /dev/null +++ b/scripts/migrations/017-imports.ts @@ -0,0 +1,19 @@ +import { ImportBatchModel } from "@/models/import-batch"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +/** Ekstre içe aktarma (Faz 17): importBatches index'i ve işlemlerde tekrar engeli index'i. */ +export const migration: Migration = { + name: "017-imports", + description: "içe aktarma partileri ve işlemlerde tekrar engeli (importKey) index'leri", + async up() { + await ImportBatchModel.createIndexes(); + await TransactionModel.createIndexes(); + }, + async down(db) { + await db + .collection("transactions") + .dropIndex("householdId_1_importKey_1") + .catch(() => {}); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 182bb00..cf532cb 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -16,6 +16,7 @@ import { migration as m013 } from "./013-stores-products"; import { migration as m014 } from "./014-product-prices"; import { migration as m015 } from "./015-shopping-lists"; import { migration as m016 } from "./016-kakeibo-groups"; +import { migration as m017 } from "./017-imports"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -36,6 +37,7 @@ export const MIGRATIONS: Migration[] = [ m014, m015, m016, + m017, ]; const COLLECTION = "migrations"; diff --git a/src/features/imports/actions.ts b/src/features/imports/actions.ts new file mode 100644 index 0000000..027849d --- /dev/null +++ b/src/features/imports/actions.ts @@ -0,0 +1,126 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { KAKEIBO_GROUPS } from "@/shared/kakeibo"; +import { parseMoney } from "@/shared/money"; +import { + commitImport, + createImportAccount, + createImportDraft, + deleteImportDraft, + MAX_IMPORT_BYTES, + revertImport, + setImportAccount, + updateImportRow, +} from "./service"; + +const t = tr.imports; +const id = z.string().regex(/^[a-f0-9]{24}$/i); +const role = z.enum(["card", "bank", "cash", "extra"]); + +async function ctx() { + return getHouseholdActionContext("member"); +} + +function done(r: ActionResult) { + if (r.ok) revalidatePath("/", "layout"); + return r; +} + +/** Dosya yükleme (FormData: file). Yalnızca .xlsx; boyut sınırı Server Action gövdesiyle uyumlu. */ +export async function uploadImportAction(form: FormData) { + const c = await ctx(); + if (!c) return fail(tr.common.unauthorized); + const file = form.get("file"); + if (!(file instanceof File) || file.size === 0) return fail(t.errors.noFile); + if (file.size > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge); + if (!/\.xlsx$/i.test(file.name)) return fail(t.errors.unreadable); + const buffer = Buffer.from(await file.arrayBuffer()); + return done(await createImportDraft(c.user.id, c.household.id, { name: file.name, buffer })); +} + +export async function setImportAccountAction(batchId: unknown, r: unknown, accountId: unknown) { + const c = await ctx(); + const p = z + .object({ batchId: id, role, accountId: id }) + .safeParse({ batchId, role: r, accountId }); + if (!c || !p.success) return fail(tr.common.notFound); + return done( + await setImportAccount( + c.user.id, + c.household.id, + p.data.batchId, + p.data.role, + p.data.accountId, + ), + ); +} + +export async function createImportAccountAction(batchId: unknown, r: unknown, input: unknown) { + const c = await ctx(); + const p = z + .object({ + batchId: id, + role, + name: z.string().trim().min(1, tr.common.required).max(80), + opening: z.string().trim(), + openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + }) + .safeParse({ batchId, role: r, ...(input as object) }); + if (!c || !p.success) return fail(tr.adminUsers.errors.checkFields); + const minor = p.data.opening ? parseMoney(p.data.opening.replace("−", "-")) : 0; + if (minor === null) return fail(tr.validation.money); + return done( + await createImportAccount(c.user.id, c.household.id, p.data.batchId, p.data.role, { + name: p.data.name, + openingMinor: minor, + openingDate: p.data.openingDate, + }), + ); +} + +export async function updateImportRowAction(batchId: unknown, edit: unknown) { + const c = await ctx(); + const p = z + .object({ + batchId: id, + rowId: id, + kind: z.enum(["expense", "income", "skip"]).optional(), + categoryId: z + .union([id, z.string().regex(/^new:[a-z.-]+$/)]) + .nullable() + .optional(), + kakeiboGroup: z.enum(KAKEIBO_GROUPS).nullable().optional(), + applyToSame: z.boolean().optional(), + }) + .safeParse({ batchId, ...(edit as object) }); + if (!c || !p.success) return fail(tr.common.notFound); + const { batchId: b, ...rest } = p.data; + // Satır düzenlemesi sık; tüm sayfayı yenilemeye gerek yok. + return updateImportRow(c.user.id, c.household.id, b, rest); +} + +export async function commitImportAction(batchId: unknown) { + const c = await ctx(); + const p = id.safeParse(batchId); + if (!c || !p.success) return fail(tr.common.notFound); + return done(await commitImport(c.user.id, c.household.id, p.data)); +} + +export async function revertImportAction(batchId: unknown) { + const c = await ctx(); + const p = id.safeParse(batchId); + if (!c || !p.success) return fail(tr.common.notFound); + return done(await revertImport(c.user.id, c.household.id, p.data)); +} + +export async function deleteImportDraftAction(batchId: unknown) { + const c = await ctx(); + const p = id.safeParse(batchId); + if (!c || !p.success) return fail(tr.common.notFound); + return done(await deleteImportDraft(c.user.id, c.household.id, p.data)); +} diff --git a/src/features/imports/service.int.test.ts b/src/features/imports/service.int.test.ts new file mode 100644 index 0000000..b5bcc47 --- /dev/null +++ b/src/features/imports/service.int.test.ts @@ -0,0 +1,264 @@ +import * as XLSX from "xlsx"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { listAccounts } from "@/features/accounts/service"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + commitImport, + createImportAccount, + createImportDraft, + getImportDraft, + revertImport, + updateImportRow, +} from "./service"; + +/** Yapay Kakeibo çalışma kitabı (gerçek veri değil; yapı gerçek dosyayla aynı). */ +const serial = (ymd: string) => + Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569; + +function workbook(): Buffer { + const card = [ + [ + "İşlem tarihi", + "Ekstre kesim", + "İşyeri", + "Kategori", + "Kakeibo", + "Kart", + "Taksit", + "Tutar (TL)", + ], + [ + serial("2026-01-05"), + serial("2026-01-28"), + "Test Market", + "Market & gıda", + "Zorunlu", + "Fiziksel kart", + null, + 100, + ], + [ + serial("2026-01-06"), + serial("2026-01-28"), + "Test Market", + "Market & gıda", + "İstek", + "Fiziksel kart", + null, + 40, + ], + [ + serial("2025-12-10"), + serial("2026-01-28"), + "Test Yapı (taksit)", + "Ev / tadilat / yapı market", + "Beklenmeyen", + "Fiziksel kart", + "1/3 (300,00 TL)", + 100, + ], + [ + serial("2025-12-10"), + serial("2026-02-28"), + "Test Yapı (taksit)", + "Ev / tadilat / yapı market", + "Beklenmeyen", + "Fiziksel kart", + "2/3 (300,00 TL)", + 100, + ], + [ + serial("2026-01-07"), + serial("2026-01-28"), + "Test Esnaf", + "Tanımsız esnaf", + null, + "Fiziksel kart", + null, + 20, + ], + [ + serial("2026-01-08"), + serial("2026-01-28"), + "Kampanya iadesi", + "Kampanya iadesi", + "İstek", + "Fiziksel kart", + null, + -10, + ], + ]; + const bank = [ + ["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"], + [serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null], + [serial("2026-01-02"), "Yol parası", "Yol parası", 500, 1500, null], + [serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -200, 1300, null], + [serial("2026-01-04"), "Kredi kartı borç ödemesi", "Kredi kartı ödemesi", -250, 1050, null], + [serial("2026-01-05"), "Giden havale (kişi)", "Havale/EFT (kişi)", -50, 1000, null], + [serial("2026-01-06"), "KMH faiz tahakkuku", "KMH faiz ve vergi", -5, 995, null], + ]; + const summary = [ + ["Kakeibo özeti"], + [ + "Ekstre kesim", + "Zorunlu", + "İstek", + "Kültür", + "Beklenmeyen", + "Toplam", + "Ekstredeki harcama", + "Fark", + ], + [serial("2026-01-28"), 0, 0, 0, 0, 0, 250, 0], + [serial("2026-02-28"), 0, 0, 0, 0, 0, 100, 0], + ]; + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), "Özet"); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(card), "Kredi Kartı"); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(bank), "Maaş Hesabı"); + return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; +} + +let ids: Awaited>; +let household = ""; +let ayse = ""; +let zeynep = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + // Tertemiz bir hane: Annemler'de ayse izleyici; testte Çakır yerine yeni hesaplar açılır. + household = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + zeynep = ids.user("zeynep"); +}); +afterAll(teardownDb); + +describe("Kakeibo çalışma kitabı içe aktarma", () => { + let batch = ""; + const accounts: Record = {}; + + it("taslak: öneriler, kontroller, ihtiyaç duyulan roller; izleyici aktaramaz", async () => { + expect( + (await createImportDraft(zeynep, household, { name: "x.xlsx", buffer: workbook() })).ok, + ).toBe(false); + const r = await createImportDraft(ayse, household, { + name: "kakeibo.xlsx", + buffer: workbook(), + }); + expect(r.ok).toBe(true); + batch = r.ok ? r.data.id : ""; + const d = (await getImportDraft(ayse, household, batch))!; + expect(d.rows).toHaveLength(11); + expect(d.checks.statements.every((s) => s.ok)).toBe(true); + expect(d.checks.balance).toEqual({ mismatchLine: null, closingMinor: 99_500 }); + expect(d.neededRoles).toEqual(["card", "bank", "cash"]); + expect(d.presets.bank).toMatchObject({ openingMinor: 100_000, openingDate: "2026-01-01" }); + expect(d.presets.card).toMatchObject({ openingMinor: 25_000 }); + expect(d.newCategories).toContain("Yol parası"); + // İşlenince oluşacak kategori önizlemede eksik sayılmaz; seçilebilir. + const yol = d.rows.find((r) => r.description === "Yol parası")!; + expect(yol.categoryId).toBe("new:kakeibo.yol"); + expect(d.categories.find((c) => c.id === "new:kakeibo.yol")).toMatchObject({ kind: "income" }); + expect( + d.rows.filter((r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId), + ).toEqual([]); + }); + + it("önerilen hesaplar oluşturulup rollere bağlanır", async () => { + const d = (await getImportDraft(ayse, household, batch))!; + for (const role of ["bank", "card", "cash"] as const) { + const p = d.presets[role]!; + const r = await createImportAccount(ayse, household, batch, role, { + name: `Test ${p.name}`, + openingMinor: p.openingMinor, + openingDate: p.openingDate, + }); + expect(r.ok).toBe(true); + if (r.ok) accounts[role] = r.data.id; + } + expect((await getImportDraft(ayse, household, batch))!.accounts).toMatchObject(accounts); + }); + + it("satır düzenleme: grupsuz havaleye kategori ve grup; aynı işyerine toplu", async () => { + const d = (await getImportDraft(ayse, household, batch))!; + const havale = d.rows.find((r) => r.description === "Giden havale (kişi)")!; + expect(havale.review).toBe("ungrouped"); + const dining = d.categories.find((c) => c.path === "Eğlence › Dışarıda yemek")!; + expect( + (await updateImportRow(ayse, household, batch, { rowId: havale.id, categoryId: dining.id })) + .ok, + ).toBe(true); + const market = d.rows.find((r) => r.description === "Test Market")!; + const multi = await updateImportRow(ayse, household, batch, { + rowId: market.id, + kakeiboGroup: "ZORUNLU", + applyToSame: true, + }); + expect(multi).toMatchObject({ ok: true, data: { updated: 2 } }); + // Geri dön: ikinci market satırı İstek kalsın (kayıt bazında grup testi için). + const second = d.rows.filter((r) => r.description === "Test Market")[1]!; + await updateImportRow(ayse, household, batch, { rowId: second.id, kakeiboGroup: "ISTEK" }); + }); + + it("işle: işlemler, transferler, taksit planı, grup, yeni kategoriler; bakiye dosyayla tutar", async () => { + const r = await commitImport(ayse, household, batch); + expect(r).toMatchObject({ ok: true, data: { transactions: 11, skipped: 0 } }); + const txs = await TransactionModel.find({ + importBatchId: { $ne: null }, + deletedAt: null, + }).lean(); + expect(txs).toHaveLength(11); + const cardPay = txs.find((x) => x.payeeName === "Kredi kartı borç ödemesi")!; + expect(cardPay).toMatchObject({ type: "transfer", amountMinor: 25_000 }); + expect(String(cardPay.accountId)).toBe(accounts.bank); + expect(String(cardPay.toAccountId)).toBe(accounts.card); + const inst = txs.filter((x) => x.installment); + expect(inst).toHaveLength(2); + expect(String(inst[0]!.installment!.planId)).toBe(String(inst[1]!.installment!.planId)); + const markets = txs.filter((x) => x.payeeName === "Test Market"); + expect(markets.map((x) => x.kakeiboGroup).sort()).toEqual(["ISTEK", null]); + const refund = txs.find((x) => x.payeeName === "Kampanya iadesi")!; + expect(refund.type).toBe("income"); + expect(await CategoryModel.exists({ defaultKey: "kakeibo.yol" })).toBeTruthy(); + + const list = (await listAccounts(ayse, household))!.accounts; + const bal = (id: string) => list.find((a) => a.id === id)!.balanceMinor; + expect(bal(accounts.bank!)).toBe(99_500); // dosyadaki son bakiye + expect(bal(accounts.cash!)).toBe(20_000); + // Kart: −250 açılış + 250 ödeme − (100 + 40 + 100 + 100 + 20) + 10 iade. + expect(bal(accounts.card!)).toBe(-35_000); + }); + + it("aynı dosya tekrar yüklenince tüm satırlar daha önce aktarılmış sayılır", async () => { + const again = await createImportDraft(ayse, household, { + name: "kakeibo.xlsx", + buffer: workbook(), + }); + const d = (await getImportDraft(ayse, household, again.ok ? again.data.id : ""))!; + expect(d.rows.every((r) => r.duplicate)).toBe(true); + expect(d.neededRoles).toEqual([]); + expect(await commitImport(ayse, household, d.id)).toMatchObject({ + ok: true, + data: { transactions: 0, skipped: 11 }, + }); + }); + + it("geri alma işlemleri siler; dosya yeniden aktarılabilir", async () => { + expect(await revertImport(ayse, household, batch)).toMatchObject({ + ok: true, + data: { transactions: 11 }, + }); + expect( + await TransactionModel.countDocuments({ importBatchId: { $ne: null }, deletedAt: null }), + ).toBe(0); + expect((await revertImport(ayse, household, batch)).ok).toBe(false); + const fresh = await createImportDraft(ayse, household, { + name: "kakeibo.xlsx", + buffer: workbook(), + }); + const d = (await getImportDraft(ayse, household, fresh.ok ? fresh.data.id : ""))!; + expect(d.rows.some((r) => r.duplicate)).toBe(false); + }); +}); diff --git a/src/features/imports/service.ts b/src/features/imports/service.ts new file mode 100644 index 0000000..4a45263 --- /dev/null +++ b/src/features/imports/service.ts @@ -0,0 +1,816 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { Types } from "mongoose"; +import * as XLSX from "xlsx"; +import { createAccount } from "@/features/accounts/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership, type Membership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { ImportBatchModel } from "@/models/import-batch"; +import { TransactionModel } from "@/models/transaction"; +import type { AccountType } from "@/shared/accounts"; +import { istanbulDayStart } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { + checkStatements, + firstCardPayment, + IMPORT_CATEGORIES, + parseBankSheet, + parseCardSheet, + parseStatementTotals, + type ImportRole, + type ProposedRow, + type RowKind, + type StatementCheck, +} from "@/shared/import/kakeibo-workbook"; +import { isKakeiboGroup, resolveKakeibo, type KakeiboGroup } from "@/shared/kakeibo"; +import { accountFormSchema } from "@/shared/schemas/account"; +import { compareTr, foldTr } from "@/shared/search"; +import { addDays } from "@/shared/recurrence"; + +/** + * Ekstre içe aktarma (Faz 17a): Kakeibo çalışma kitabı. Okuma: member; hesap oluşturma owner + * (hesap servisinin kuralı). Akış: dosya → taslak (öneriler, tekrar işaretleri, kontroller) → + * hesap eşleme → satır düzenleme → tek transaction'da işle → gerekirse toplu geri al. + * + * Kişisel veri: dosyanın başlık alanları (müşteri/kart no) okunmaz; satır açıklamaları yalnızca + * hanenin kendi verisinde saklanır. Tekrar engeli için işlemde anahtarın SHA-256 özeti tutulur. + */ + +const t = tr.imports; +// Server Action gövde sınırı (Next varsayılanı 1 MB) ile uyumlu. +export const MAX_IMPORT_BYTES = 1024 * 1024 - 64 * 1024; +const ROLES: ImportRole[] = ["card", "bank", "cash", "extra"]; +const ROLE_TYPES: Record = { + card: ["credit_card"], + bank: ["bank"], + cash: ["cash"], + extra: ["bank", "cash"], + gold: [], +}; + +type RowDoc = ProposedRow & { + _id: Types.ObjectId; + categoryId: Types.ObjectId | null; + duplicate: boolean; + edited: boolean; +}; + +type BatchDoc = { + _id: Types.ObjectId; + householdId: Types.ObjectId; + fileName: string; + fileHash: string; + status: "draft" | "committed" | "reverted"; + accounts: Partial>; + rows: RowDoc[]; + checks: ImportChecks; + committed: { transactions: number; skipped: number; categoriesCreated: number } | null; + committedAt: Date | null; + revertedAt: Date | null; + createdAt: Date; +}; + +export type ImportChecks = { + statements: StatementCheck[]; + balance: { mismatchLine: number | null; closingMinor: number | null }; + opening: { + bank: { date: string; balanceMinor: number } | null; + card: { date: string; amountMinor: number } | null; + }; + /** Kart satırlarının en erken tarihi (kart açılış tarihi önerisi). */ + firstCardDate: string | null; + firstBankDate: string | null; +}; + +const hashKey = (key: string) => createHash("sha256").update(key).digest("hex"); + +// --- Dosya okuma --- + +/** Kakeibo çalışma kitabını okur. Sayfalar yoksa açık hata döner. */ +export function readWorkbook( + buffer: Buffer, +): ActionResult<{ rows: ProposedRow[]; checks: ImportChecks }> { + let wb: XLSX.WorkBook; + try { + wb = XLSX.read(buffer, { type: "buffer", cellDates: false, dense: true }); + } catch { + return fail(t.errors.unreadable); + } + const sheet = (name: string) => { + const ws = wb.Sheets[name]; + return ws + ? (XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as unknown[][]) + : null; + }; + const cardRows = sheet("Kredi Kartı"); + const bankRows = sheet("Maaş Hesabı"); + if (!cardRows && !bankRows) return fail(t.errors.noSheets); + const card = cardRows ? parseCardSheet(cardRows) : []; + const bank = bankRows ? parseBankSheet(bankRows) : null; + const summary = sheet("Özet"); + const rows = [...card, ...(bank?.rows ?? [])]; + const dates = (s: "card" | "bank") => + rows + .filter((r) => r.sheet === s) + .map((r) => r.date) + .sort(); + return ok({ + rows, + checks: { + statements: summary ? checkStatements(card, parseStatementTotals(summary)) : [], + balance: { + mismatchLine: bank?.balanceMismatch?.line ?? null, + closingMinor: bank?.closingBalanceMinor ?? null, + }, + opening: { bank: bank?.opening ?? null, card: firstCardPayment(rows) }, + firstCardDate: dates("card")[0] ?? null, + firstBankDate: dates("bank")[0] ?? null, + }, + }); +} + +// --- Görünümler --- + +export type ImportRowView = { + id: string; + sheet: "card" | "bank"; + line: number; + date: string; + statementCut: string | null; + description: string; + amountMinor: number; + kind: RowKind; + categoryId: string | null; + kakeiboGroup: KakeiboGroup | null; + counterRole: ImportRole | null; + installment: { index: number; count: number; totalMinor: number } | null; + source: { category: string; type: string }; + review: string | null; + duplicate: boolean; + edited: boolean; +}; + +export type ImportSummary = { + id: string; + fileName: string; + status: BatchDoc["status"]; + createdAt: string; + committedAt: string | null; + revertedAt: string | null; + rowCount: number; + committed: BatchDoc["committed"]; +}; + +export type AccountPreset = { + role: ImportRole; + name: string; + type: AccountType; + openingMinor: number; + openingDate: string; +}; + +export type ImportDraft = ImportSummary & { + rows: ImportRowView[]; + checks: ImportChecks; + accounts: Partial>; + /** Satırların ihtiyaç duyduğu roller. */ + neededRoles: ImportRole[]; + accountOptions: { id: string; name: string; type: AccountType }[]; + presets: Record; + categories: { + id: string; + path: string; + kind: "expense" | "income"; + group: KakeiboGroup | null; + bankCost: boolean; + }[]; + /** Henüz hanede olmayan ve işlenince oluşturulacak kategoriler. */ + newCategories: string[]; + canCreateAccounts: boolean; +}; + +const summaryOf = (b: BatchDoc): ImportSummary => ({ + id: String(b._id), + fileName: b.fileName, + status: b.status, + createdAt: b.createdAt.toISOString(), + committedAt: b.committedAt?.toISOString() ?? null, + revertedAt: b.revertedAt?.toISOString() ?? null, + rowCount: b.rows.length, + committed: b.committed, +}); + +type CatLean = { + _id: Types.ObjectId; + kind: "expense" | "income"; + name: string; + parentId: Types.ObjectId | null; + defaultKey: string | null; + archivedAt: Date | null; + kakeiboGroup?: KakeiboGroup | null; + bankCost?: boolean; +}; + +async function loadCategories(householdId: Types.ObjectId) { + const cats = await CategoryModel.find({ householdId }).lean(); + const byId = new Map(cats.map((c) => [String(c._id), c])); + const path = (c: CatLean): string => { + const p = c.parentId ? byId.get(String(c.parentId)) : undefined; + return p ? `${path(p)} › ${c.name}` : c.name; + }; + const resolved = resolveKakeibo( + cats.map((c) => ({ + id: String(c._id), + parentId: c.parentId ? String(c.parentId) : null, + kakeiboGroup: c.kakeiboGroup ?? null, + bankCost: Boolean(c.bankCost), + })), + ); + const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c])); + return { cats, byId, byKey, path, resolved }; +} + +const rowCategoryId = (r: RowDoc, byKey: Map) => + r.categoryId + ? String(r.categoryId) + : r.categoryKey + ? byKey.get(r.categoryKey) + ? String(byKey.get(r.categoryKey)!._id) + : null + : null; + +/** İşlenirken oluşturulacak kategori için önizlemedeki sözde kimlik. */ +export const NEW_CATEGORY_PREFIX = "new:"; +const IMPORT_KEYS = new Set(IMPORT_CATEGORIES.map((x) => x.key)); +const draftCategoryId = (r: RowDoc, byKey: Map) => + rowCategoryId(r, byKey) ?? + (r.categoryId === null && r.categoryKey && IMPORT_KEYS.has(r.categoryKey) + ? NEW_CATEGORY_PREFIX + r.categoryKey + : null); + +function neededRoles(rows: readonly RowDoc[]): ImportRole[] { + const need = new Set(); + for (const r of rows) { + if (r.kind === "skip" || r.kind === "deferred" || r.duplicate) continue; + need.add(r.sheet); + if (r.kind === "transfer" && r.counterRole) need.add(r.counterRole); + } + return ROLES.filter((x) => need.has(x)); +} + +async function loadBatch(m: Membership, batchId: string) { + const id = toObjectId(batchId); + if (!id) return null; + return ImportBatchModel.findOne({ _id: id, householdId: m.householdId }).lean(); +} + +// --- Okuma --- + +export async function listImports( + userId: string, + householdId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "member"); + if (!m) return null; + const rows = await ImportBatchModel.find({ householdId: m.householdId }) + .select({ rows: 0 }) + .sort({ createdAt: -1 }) + .limit(50) + .lean<(Omit & { rows?: undefined })[]>(); + const counts = await ImportBatchModel.aggregate<{ _id: Types.ObjectId; n: number }>([ + { $match: { householdId: m.householdId } }, + { $project: { n: { $size: "$rows" } } }, + ]); + const n = new Map(counts.map((c) => [String(c._id), c.n])); + return rows.map((b) => ({ + ...summaryOf({ ...b, rows: [] } as BatchDoc), + rowCount: n.get(String(b._id)) ?? 0, + })); +} + +export async function getImportDraft( + userId: string, + householdId: string, + batchId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "member"); + if (!m) return null; + const b = await loadBatch(m, batchId); + if (!b) return null; + const [cats, accounts] = await Promise.all([ + loadCategories(m.householdId), + AccountModel.find({ householdId: m.householdId, archivedAt: null }) + .select({ name: 1, type: 1, isPrivate: 1, ownerUserId: 1 }) + .lean< + { + _id: Types.ObjectId; + name: string; + type: AccountType; + isPrivate: boolean; + ownerUserId: Types.ObjectId | null; + }[] + >(), + ]); + const c = b.checks; + const presets: Record = { + bank: { + role: "bank", + name: t.presets.bank, + type: "bank", + openingMinor: c.opening.bank?.balanceMinor ?? 0, + openingDate: c.opening.bank + ? addDays(c.opening.bank.date, 1) + : (c.firstBankDate ?? "2026-01-01"), + }, + card: { + role: "card", + name: t.presets.card, + type: "credit_card", + openingMinor: c.opening.card?.amountMinor ?? 0, + openingDate: c.firstCardDate ?? "2026-01-01", + }, + cash: { + role: "cash", + name: t.presets.cash, + type: "cash", + openingMinor: 0, + openingDate: c.firstBankDate ?? "2026-01-01", + }, + extra: { + role: "extra", + name: t.presets.extra, + type: "bank", + openingMinor: 0, + openingDate: c.firstBankDate ?? "2026-01-01", + }, + }; + const visible = accounts.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId); + const missingCats = IMPORT_CATEGORIES.filter((x) => !cats.byKey.has(x.key)); + const missing = missingCats.map((x) => x.name); + return { + ...summaryOf(b), + rows: b.rows.map((r) => ({ + id: String(r._id), + sheet: r.sheet, + line: r.line, + date: r.date, + statementCut: r.statementCut, + description: r.description, + amountMinor: r.amountMinor, + kind: r.kind, + categoryId: draftCategoryId(r, cats.byKey), + kakeiboGroup: isKakeiboGroup(r.kakeiboGroup) ? r.kakeiboGroup : null, + counterRole: r.counterRole, + installment: r.installment + ? { + index: r.installment.index, + count: r.installment.count, + totalMinor: r.installment.totalMinor, + } + : null, + source: r.source, + review: r.review, + duplicate: r.duplicate, + edited: r.edited, + })), + checks: b.checks, + accounts: b.accounts ?? {}, + neededRoles: neededRoles(b.rows), + accountOptions: visible + .map((a) => ({ id: String(a._id), name: a.name, type: a.type })) + .sort((x, y) => compareTr(x.name, y.name)), + presets, + categories: cats.cats + .filter((x) => !x.archivedAt) + .map((x) => { + const k = cats.resolved.get(String(x._id)); + return { + id: String(x._id), + path: cats.path(x), + kind: x.kind, + group: k?.group ?? null, + bankCost: k?.bankCost ?? false, + }; + }) + .concat( + missingCats.map((x) => ({ + id: NEW_CATEGORY_PREFIX + x.key, + path: t.newCategoryName(x.name), + kind: x.kind, + group: x.kind === "expense" ? x.kakeiboGroup : null, + bankCost: false, + })), + ) + .sort((x, y) => compareTr(x.path, y.path)), + newCategories: missing, + canCreateAccounts: hasHouseholdRole(m.role, "owner"), + }; +} + +// --- Yazma --- + +export async function createImportDraft( + actorId: string, + householdId: string, + file: { name: string; buffer: Buffer }, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + if (file.buffer.byteLength > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge); + const parsed = readWorkbook(file.buffer); + if (!parsed.ok) return parsed; + const { rows, checks } = parsed.data; + if (!rows.length) return fail(t.errors.empty); + // Hanede zaten içe aktarılmış satırlar (aynı anahtar) atlanmak üzere işaretlenir. + const hashes = rows.map((r) => hashKey(r.key)); + const existing = new Set( + ( + await TransactionModel.find({ householdId: m.householdId, importKey: { $in: hashes } }) + .select({ importKey: 1 }) + .lean<{ importKey: string }[]>() + ).map((x) => x.importKey), + ); + const actor = new Types.ObjectId(actorId); + const doc = await ImportBatchModel.create({ + householdId: m.householdId, + source: "kakeibo-workbook", + fileName: file.name.slice(0, 200), + fileHash: createHash("sha256").update(file.buffer).digest("hex"), + rows: rows.map((r, i) => ({ ...r, duplicate: existing.has(hashes[i]!) })), + checks, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +async function draftFor(actorId: string, householdId: string, batchId: string) { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return { error: fail(tr.common.unauthorized) } as const; + const b = await loadBatch(m, batchId); + if (!b) return { error: fail(tr.common.notFound) } as const; + if (b.status !== "draft") return { error: fail(t.errors.notDraft) } as const; + return { error: null, m, b, actor: new Types.ObjectId(actorId) } as const; +} + +/** Bir rolü mevcut bir hesaba bağlar. */ +export async function setImportAccount( + actorId: string, + householdId: string, + batchId: string, + role: ImportRole, + accountId: string, +): Promise { + const r = await draftFor(actorId, householdId, batchId); + if (r.error) return r.error; + const id = toObjectId(accountId); + const acc = id + ? await AccountModel.findOne({ _id: id, householdId: r.m.householdId, archivedAt: null }) + .select({ type: 1 }) + .lean<{ type: AccountType }>() + : null; + if (!acc || !ROLE_TYPES[role].includes(acc.type)) return fail(t.errors.accountType); + await ImportBatchModel.updateOne( + { _id: r.b._id }, + { $set: { [`accounts.${role}`]: accountId, updatedBy: r.actor } }, + ); + return ok(undefined); +} + +/** Önerilen değerlerle hesap oluşturup role bağlar (hesap oluşturma: owner). */ +export async function createImportAccount( + actorId: string, + householdId: string, + batchId: string, + role: ImportRole, + input: { name: string; openingMinor: number; openingDate: string }, +): Promise> { + const draft = await getImportDraft(actorId, householdId, batchId); + if (!draft || draft.status !== "draft") return fail(tr.common.notFound); + const preset = draft.presets[role]; + if (!preset) return fail(tr.common.notFound); + const isCard = preset.type === "credit_card"; + const form = accountFormSchema.safeParse({ + name: input.name, + type: preset.type, + institution: "", + mealCardProvider: null, + // Kartta açılış borcu pozitif girilir; hesap servisi negatife çevirir. Bankada eksi bakiye + // (KMH) eksi işaretle girilir. + openingBalance: formatMoneyInput(isCard ? Math.abs(input.openingMinor) : input.openingMinor), + openingDate: input.openingDate, + color: isCard ? "purple" : preset.type === "cash" ? "green" : "blue", + icon: isCard ? "credit-card" : preset.type === "cash" ? "wallet" : "landmark", + ownerUserId: "", + isPrivate: false, + overdraftEnabled: role === "bank", + overdraftLimit: "0", + overdraftMonthlyRate: "", + overdraftStatementDay: "", + cardLimit: "0", + cardStatementDay: "28", + cardDueKind: "daysAfter", + cardDueValue: "12", + cardPaymentAccountId: "", + cardMinimumRate: "", + }); + if (!form.success) return fail(form.error.issues[0]?.message ?? t.errors.accountType); + const created = await createAccount(actorId, householdId, form.data); + if (!created.ok) return created; + const set = await setImportAccount(actorId, householdId, batchId, role, created.data.id); + if (!set.ok) return set; + return ok({ id: created.data.id }); +} + +export type ImportRowEdit = { + rowId: string; + kind?: "expense" | "income" | "skip"; + categoryId?: string | null; + kakeiboGroup?: KakeiboGroup | null; + /** Aynı açıklamalı (işyeri) tüm satırlara uygula. */ + applyToSame?: boolean; +}; + +export async function updateImportRow( + actorId: string, + householdId: string, + batchId: string, + edit: ImportRowEdit, +): Promise> { + const r = await draftFor(actorId, householdId, batchId); + if (r.error) return r.error; + const row = r.b.rows.find((x) => String(x._id) === edit.rowId); + if (!row) return fail(tr.common.notFound); + const newKey = edit.categoryId?.startsWith(NEW_CATEGORY_PREFIX) + ? edit.categoryId.slice(NEW_CATEGORY_PREFIX.length) + : null; + if (newKey !== null && !IMPORT_KEYS.has(newKey)) + return fail(tr.transactions.validation.categoryInvalid); + if (edit.categoryId && newKey === null) { + const ok = await CategoryModel.exists({ + _id: new Types.ObjectId(edit.categoryId), + householdId: r.m.householdId, + archivedAt: null, + }); + if (!ok) return fail(tr.transactions.validation.categoryInvalid); + } + const targets = edit.applyToSame + ? r.b.rows.filter( + (x) => + x.sheet === row.sheet && + foldTr(x.description) === foldTr(row.description) && + x.kind === row.kind, + ) + : [row]; + const ops = targets.map((x) => { + const s: Record = { "rows.$[r].edited": true }; + if (edit.kind) s["rows.$[r].kind"] = edit.kind; + if (newKey !== null) { + s["rows.$[r].categoryId"] = null; + s["rows.$[r].categoryKey"] = newKey; + } else if (edit.categoryId !== undefined) + s["rows.$[r].categoryId"] = edit.categoryId ? new Types.ObjectId(edit.categoryId) : null; + if (edit.kakeiboGroup !== undefined) s["rows.$[r].kakeiboGroup"] = edit.kakeiboGroup; + return { + updateOne: { + filter: { _id: r.b._id }, + update: { $set: { ...s, updatedBy: r.actor } }, + arrayFilters: [{ "r._id": x._id }], + }, + }; + }); + if (ops.length) await ImportBatchModel.bulkWrite(ops); + return ok({ updated: ops.length }); +} + +/** Partideki satırları tek transaction'da işler. */ +export async function commitImport( + actorId: string, + householdId: string, + batchId: string, +): Promise> { + const r = await draftFor(actorId, householdId, batchId); + if (r.error) return r.error; + const { m, b, actor } = r; + const need = neededRoles(b.rows); + const accountIds: Partial> = {}; + for (const role of need) { + const id = b.accounts?.[role] ? toObjectId(b.accounts[role]!) : null; + const acc = id + ? await AccountModel.findOne({ _id: id, householdId: m.householdId, archivedAt: null }) + .select({ type: 1 }) + .lean<{ type: AccountType }>() + : null; + if (!acc || !ROLE_TYPES[role].includes(acc.type)) + return fail(t.errors.roleMissing(t.roles[role] ?? role)); + accountIds[role] = id!; + } + + const mongoose = await connectMongoose(); + let inserted = 0; + let skipped = 0; + let categoriesCreated = 0; + try { + await mongoose.connection.transaction(async (session) => { + inserted = 0; + skipped = 0; + categoriesCreated = 0; + // Eksik içe aktarma kategorileri (anahtar: kakeibo.*). + const existing = await CategoryModel.find({ + householdId: m.householdId, + defaultKey: { $in: IMPORT_CATEGORIES.map((x) => x.key) }, + }) + .select({ defaultKey: 1 }) + .session(session) + .lean<{ defaultKey: string }[]>(); + const have = new Set(existing.map((x) => x.defaultKey)); + const missing = IMPORT_CATEGORIES.filter((x) => !have.has(x.key)); + if (missing.length) { + await CategoryModel.insertMany( + missing.map((x, i) => ({ + householdId: m.householdId, + kind: x.kind, + name: x.name, + nameKey: foldTr(x.name), + parentId: null, + ancestors: [], + icon: x.icon, + color: x.color, + order: 100 + i, + defaultKey: x.key, + kakeiboGroup: x.kind === "expense" ? x.kakeiboGroup : null, + bankCost: false, + createdBy: actor, + updatedBy: actor, + })), + { session }, + ); + categoriesCreated = missing.length; + } + const cats = await CategoryModel.find({ householdId: m.householdId }) + .session(session) + .lean(); + const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c])); + const byId = new Map(cats.map((c) => [String(c._id), c])); + const resolved = resolveKakeibo( + cats.map((c) => ({ + id: String(c._id), + parentId: c.parentId ? String(c.parentId) : null, + kakeiboGroup: c.kakeiboGroup ?? null, + bankCost: Boolean(c.bankCost), + })), + ); + const plans = new Map(); + const docs: Record[] = []; + for (const row of b.rows) { + if (row.duplicate || row.kind === "skip" || row.kind === "deferred") { + skipped++; + continue; + } + const own = accountIds[row.sheet]!; + const base = { + householdId: m.householdId, + status: "posted", + date: istanbulDayStart(row.date)!, + amountMinor: Math.abs(row.amountMinor), + payeeName: row.description.slice(0, 80), + description: "", + searchText: foldTr(row.description), + tags: [t.tag], + importBatchId: b._id, + importKey: hashKey(row.key), + createdBy: actor, + updatedBy: actor, + }; + if (row.kind === "transfer") { + const other = accountIds[row.counterRole as ImportRole]!; + const out = row.amountMinor < 0; + docs.push({ + ...base, + type: "transfer", + accountId: out ? own : other, + toAccountId: out ? other : own, + categoryId: null, + splits: [], + effectiveCategoryIds: [], + }); + continue; + } + const catId = rowCategoryId(row, byKey); + const cat = catId ? byId.get(catId) : undefined; + const kind = row.kind as "expense" | "income"; + if (!cat || cat.kind !== kind || cat.archivedAt) + throw new ImportError(fail(t.errors.category(row.description || String(row.line)))); + const group = isKakeiboGroup(row.kakeiboGroup) ? row.kakeiboGroup : null; + const categoryGroup = resolved.get(catId!)?.group ?? null; + let installment = null; + if (row.installment && kind === "expense") { + const planId = plans.get(row.installment.key) ?? new Types.ObjectId(); + plans.set(row.installment.key, planId); + installment = { planId, index: row.installment.index, count: row.installment.count }; + } + docs.push({ + ...base, + type: kind, + accountId: own, + toAccountId: null, + categoryId: cat._id, + splits: [], + effectiveCategoryIds: [cat._id], + kakeiboGroup: kind === "expense" && group && group !== categoryGroup ? group : null, + installment, + }); + } + if (docs.length) await TransactionModel.insertMany(docs, { session }); + inserted = docs.length; + await ImportBatchModel.updateOne( + { _id: b._id, status: "draft" }, + { + $set: { + status: "committed", + committedAt: new Date(), + committed: { transactions: inserted, skipped, categoriesCreated }, + updatedBy: actor, + }, + }, + { session }, + ); + }); + } catch (error) { + if (error instanceof ImportError) return error.result; + if ((error as { code?: number }).code === 11000) return fail(t.errors.duplicate); + throw error; + } + await writeAudit({ + action: "import.commit", + actorId: actor, + householdId: m.householdId, + targetType: "importBatch", + targetId: String(b._id), + diff: { transactions: inserted, skipped, categoriesCreated, fileName: b.fileName }, + }); + return ok({ transactions: inserted, skipped }); +} + +class ImportError extends Error { + constructor(readonly result: ActionResult) { + super(result.ok ? "" : result.error); + } +} + +/** İşlenmiş partinin işlemlerini siler (soft delete; anahtar boşaltılır, yeniden içe aktarılabilir). */ +export async function revertImport( + actorId: string, + householdId: string, + batchId: string, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const b = await loadBatch(m, batchId); + if (!b) return fail(tr.common.notFound); + if (b.status !== "committed") return fail(t.errors.notCommitted); + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + let n = 0; + await mongoose.connection.transaction(async (session) => { + const res = await TransactionModel.updateMany( + { householdId: m.householdId, importBatchId: b._id, deletedAt: null }, + { $set: { deletedAt: new Date(), importKey: null, updatedBy: actor } }, + { session }, + ); + n = res.modifiedCount; + await ImportBatchModel.updateOne( + { _id: b._id }, + { $set: { status: "reverted", revertedAt: new Date(), updatedBy: actor } }, + { session }, + ); + }); + await writeAudit({ + action: "import.revert", + actorId: actor, + householdId: m.householdId, + targetType: "importBatch", + targetId: String(b._id), + diff: { transactions: n, fileName: b.fileName }, + }); + return ok({ transactions: n }); +} + +/** Taslağı siler (yalnızca işlenmemiş). */ +export async function deleteImportDraft( + actorId: string, + householdId: string, + batchId: string, +): Promise { + const r = await draftFor(actorId, householdId, batchId); + if (r.error) return r.error; + await ImportBatchModel.deleteOne({ _id: r.b._id, status: "draft" }); + return ok(undefined); +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 61ebe19..2f4d371 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -340,6 +340,7 @@ export const tr = { deleteDescription: "İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.", export: "CSV indir", + import: "İçe aktar", csv: { header: [ "İşlem No", @@ -1862,6 +1863,145 @@ export const tr = { identifierRequired: "Kullanıcı adı veya e-posta girin", passwordRequired: "Şifre girin", }, + imports: { + title: "Ekstre içe aktarma", + description: + "Kakeibo çalışma kitabından kredi kartı ve maaş hesabı hareketlerini önizleyerek aktarın. Aynı satır iki kez aktarılmaz; işlenen aktarma tek tuşla geri alınır.", + tag: "içe-aktarma", + upload: "Dosya yükle", + uploadHint: + 'Kakeibo çalışma kitabı (.xlsx): "Kredi Kartı" ve "Maaş Hesabı" sayfaları, "Özet" ekstre kontrolü için.', + uploading: "Okunuyor…", + history: "Aktarmalar", + empty: "Henüz aktarma yok.", + open: "Aç", + statuses: { draft: "Taslak", committed: "İşlendi", reverted: "Geri alındı" } as Record< + string, + string + >, + rowsCount: (n: number) => `${n} satır`, + back: "Ekstre içe aktarma", + steps: { + accounts: "1. Hesaplar", + checks: "2. Kontroller", + rows: "3. Önizleme", + commit: "4. İşle", + }, + roles: { + card: "Kredi kartı", + bank: "Maaş hesabı (KMH)", + cash: "Nakit", + extra: "Ek hesap", + gold: "Altın hesabı", + } as Record, + roleHints: { + card: "Kart ekstresindeki harcama ve iadeler bu hesaba yazılır.", + bank: "Maaş hesabı hareketleri; kart ödemeleri bu hesaptan karta transfer olur.", + cash: "ATM nakit çekimleri bu hesaba transfer olur (gider değildir).", + extra: '"Hesaplar arası" havaleler bu hesaba transfer olur.', + gold: "", + } as Record, + presets: { + bank: "Yapı Kredi Maaş", + card: "KoçAilem Worldcard", + cash: "Nakit", + extra: "Ek Hesap", + }, + selectAccount: "Hesap seçin", + createSuggested: "Önerilen hesabı oluştur", + createTitle: (role: string) => `${role} hesabı oluştur`, + accountName: "Hesap adı", + openingBalance: "Açılış bakiyesi (₺)", + openingDebt: "Açılış borcu (₺)", + openingDate: "Açılış tarihi", + openingHintBank: "Dosyadaki devreden bakiye. Eksi bakiye (KMH) için başına − koyun.", + openingHintCard: + "Önceki ekstrenin borcu (dosyadaki ilk kart ödemesi). Kesim günü 28, son ödeme +12 gün; Hesaplar sayfasından düzenlenebilir.", + ownerOnly: "Hesap oluşturmak için hane sahibi olmalısınız; mevcut bir hesabı seçin.", + checksTitle: "Dosya kontrolleri", + statementOk: (cut: string) => `${cut} ekstresi tutuyor`, + statementBad: (cut: string, rows: string, statement: string) => + `${cut} ekstresi tutmuyor: satırlar ${rows}, ekstre ${statement}`, + statementsSummary: (ok: number, total: number) => + `${total} ekstrenin ${ok}'i satır toplamıyla birebir aynı.`, + noStatements: "Özet sayfası yok; ekstre toplamı kontrol edilemedi.", + balanceOk: (closing: string) => `Maaş hesabı bakiye zinciri tam; son bakiye ${closing}.`, + balanceBad: (line: number) => `Maaş hesabı bakiye sütunu ${line}. satırda tutmuyor.`, + duplicates: (n: number) => `${n} satır daha önce içe aktarılmış; atlanacak.`, + deferred: (n: number, net: string) => + `${n} altın satırı (net ${net}) altın hesabı dosyasıyla eşleştirilmek üzere bekletiliyor; o zamana dek maaş hesabı bakiyesi bu tutar kadar farklı görünür.`, + newCategories: (names: string) => `İşlenince oluşturulacak kategoriler: ${names}.`, + newCategoryName: (name: string) => `${name} (yeni)`, + filters: { + all: "Tümü", + review: "İnceleme gereken", + ungrouped: "Grupsuz", + transfer: "Transfer", + skip: "Atlanacak", + }, + sheets: { card: "Kredi kartı", bank: "Maaş hesabı" }, + columns: { + date: "Tarih", + description: "Açıklama", + amount: "Tutar", + kind: "Tür", + category: "Kategori", + group: "Grup", + }, + kinds: { + expense: "Gider", + income: "Gelir", + transfer: "Transfer", + skip: "Atla", + deferred: "Bekliyor", + } as Record, + transferTo: (role: string) => `→ ${role}`, + transferFrom: (role: string) => `← ${role}`, + reviews: { + ungrouped: "Grup seçin", + unknownCategory: "Kategori eşleşmedi", + unknownType: "Tür tanınmadı", + signMismatch: "Yön tutarsız", + promo: "Promosyon", + } as Record, + duplicate: "Daha önce aktarılmış", + installment: (i: number, n: number) => `Taksit ${i}/${n}`, + applyToSame: "Aynı açıklamalı tüm satırlara uygula", + page: (from: number, to: number, total: number) => `${from}–${to} / ${total}`, + prev: "Önceki", + next: "Sonraki", + commitTitle: "İşle", + commitSummary: (n: number, skipped: number) => + `${n} işlem oluşturulacak, ${skipped} satır atlanacak.`, + commitHint: + "İşlem tek seferde yapılır; bir hata olursa hiçbir kayıt yazılmaz. Sonradan tek tuşla geri alınabilir.", + commit: "İçe aktar", + committing: "İşleniyor…", + committed: (n: number) => `${n} işlem içe aktarıldı.`, + committedSummary: (n: number, skipped: number, cats: number) => + `${n} işlem oluşturuldu, ${skipped} satır atlandı${cats ? `, ${cats} kategori eklendi` : ""}.`, + revert: "Aktarmayı geri al", + revertTitle: "Aktarma geri alınsın mı?", + revertDescription: + "Bu aktarmayla oluşan tüm işlemler silinir (oluşturulan hesap ve kategoriler kalır). Aynı dosya daha sonra yeniden aktarılabilir.", + reverted: (n: number) => `${n} işlem geri alındı.`, + deleteDraft: "Taslağı sil", + goTransactions: "İşlemlere git", + errors: { + unreadable: "Dosya okunamadı. Kakeibo çalışma kitabını .xlsx olarak yükleyin.", + noSheets: 'Dosyada "Kredi Kartı" ya da "Maaş Hesabı" sayfası bulunamadı.', + empty: "Dosyada aktarılacak satır yok.", + tooLarge: "Dosya 1 MB'tan büyük olamaz.", + notDraft: "Bu aktarma işlenmiş; değiştirilemez.", + notCommitted: "Yalnızca işlenmiş aktarma geri alınabilir.", + accountType: "Bu rol için uygun türde bir hesap seçin.", + roleMissing: (role: string) => `"${role}" için hesap seçilmedi.`, + category: (row: string) => `Kategori seçilmemiş satır var: ${row}`, + duplicate: + "Bazı satırlar bu arada başka bir aktarmayla eklenmiş; sayfayı yenileyip tekrar deneyin.", + noFile: "Bir dosya seçin.", + }, + }, kakeibo: { groups: { ZORUNLU: "Zorunlu", diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 16fe27c..02f2627 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -46,6 +46,8 @@ export const AUDIT_ACTIONS = [ "product.unarchive", "price.delete", "shoppingList.delete", + "import.commit", + "import.revert", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/import-batch.ts b/src/models/import-batch.ts new file mode 100644 index 0000000..7b0affe --- /dev/null +++ b/src/models/import-batch.ts @@ -0,0 +1,64 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Ekstre içe aktarma partisi (Faz 17). Taslak (önizleme, düzenleme) → işlendi → geri alındı. + * Satırlar önerilen sınıflandırmayla birlikte saklanır; kullanıcı düzenlemeleri satırda tutulur. + * İşlenince oluşan işlemler `importBatchId` ile partiye bağlıdır (toplu geri alma). + */ +const importRowSchema = new Schema( + { + sheet: { type: String, enum: ["card", "bank"], required: true }, + line: { type: Number, required: true }, + date: { type: String, required: true }, + statementCut: { type: String, default: null }, + description: { type: String, default: "" }, + amountMinor: { type: Number, required: true }, + kind: { + type: String, + enum: ["expense", "income", "transfer", "skip", "deferred"], + required: true, + }, + categoryKey: { type: String, default: null }, + /** Kullanıcının seçtiği kategori (varsa categoryKey'in yerine). */ + categoryId: { type: Schema.Types.ObjectId, default: null }, + kakeiboGroup: { type: String, default: null }, + counterRole: { type: String, default: null }, + installment: { type: Schema.Types.Mixed, default: null }, + source: { type: Schema.Types.Mixed, default: {} }, + review: { type: String, default: null }, + key: { type: String, required: true }, + /** Bu anahtar hanede zaten içe aktarılmış (varsayılan: atlanır). */ + duplicate: { type: Boolean, default: false }, + edited: { type: Boolean, default: false }, + }, + { _id: true }, +); + +const importBatchSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + source: { type: String, enum: ["kakeibo-workbook"], required: true }, + fileName: { type: String, required: true, maxlength: 200 }, + fileHash: { type: String, required: true }, + status: { type: String, enum: ["draft", "committed", "reverted"], default: "draft" }, + /** Rol → hesap (card, bank, cash, extra). */ + accounts: { type: Schema.Types.Mixed, default: {} }, + rows: { type: [importRowSchema], default: [] }, + /** Dosyadan çıkan kontroller (ekstre toplamları, bakiye zinciri, açılış). */ + checks: { type: Schema.Types.Mixed, default: {} }, + committed: { type: Schema.Types.Mixed, default: null }, + committedAt: { type: Date, default: null }, + revertedAt: { type: Date, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "importBatches" }, +); + +importBatchSchema.index({ householdId: 1, createdAt: -1 }); + +export type ImportBatch = InferSchemaType; + +export const ImportBatchModel: Model = + (models.ImportBatch as Model | undefined) ?? + model("ImportBatch", importBatchSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index e715b68..05694bd 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -81,6 +81,9 @@ const transactionSchema = new Schema( reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] }, /** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */ isAdjustment: { type: Boolean, default: false }, + /** Ekstre içe aktarmasından geldiyse parti ve tekrar engeli anahtarı (Faz 17). */ + importBatchId: { type: Schema.Types.ObjectId, default: null }, + importKey: { type: String, default: null }, /** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */ shoppingListId: { type: Schema.Types.ObjectId, default: null }, deletedAt: { type: Date, default: null }, @@ -92,6 +95,15 @@ const transactionSchema = new Schema( { timestamps: true, collection: "transactions" }, ); +// Aynı ekstre satırı iki kez içe aktarılamaz. Geri alınan partide anahtar boşaltılır (yeniden +// içe aktarılabilir). +transactionSchema.index( + { householdId: 1, importKey: 1 }, + { + unique: true, + partialFilterExpression: { importKey: { $type: "string" } }, + }, +); 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 }); diff --git a/src/shared/import/kakeibo-workbook.test.ts b/src/shared/import/kakeibo-workbook.test.ts new file mode 100644 index 0000000..393fe37 --- /dev/null +++ b/src/shared/import/kakeibo-workbook.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; +import { + bankExpenseCategory, + checkStatements, + excelDate, + firstCardPayment, + parseBankSheet, + parseCardSheet, + parseInstallment, + parseStatementTotals, + refineCategory, +} from "./kakeibo-workbook"; + +/** "YYYY-MM-DD" → Excel seri günü (test verisi için). */ +const serial = (ymd: string) => + Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569; + +const CARD_HEAD = [ + "İşlem tarihi", + "Ekstre kesim", + "İşyeri", + "Kategori", + "Kakeibo", + "Kart", + "Taksit", + "Tutar (TL)", +]; +const BANK_HEAD = ["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"]; + +describe("hücre yardımcıları", () => { + it("Excel seri günü saat dilimi kaymadan güne çözülür", () => { + expect(excelDate(serial("2026-01-28"))).toBe("2026-01-28"); + expect(excelDate(serial("2025-12-31"))).toBe("2025-12-31"); + expect(excelDate("x")).toBeNull(); + }); + it("taksit metni", () => { + expect(parseInstallment("2/7 (3.465,95 TL)")).toEqual({ + index: 2, + count: 7, + totalMinor: 346_595, + }); + expect(parseInstallment("1/9 (28.200,00 TL)")).toEqual({ + index: 1, + count: 9, + totalMinor: 2_820_000, + }); + expect(parseInstallment("")).toBeNull(); + }); + it("işyerine göre alt kategori ve banka açıklaması", () => { + expect(refineCategory("faturalar", "CK Boğaziçi Elektrik")).toBe("faturalar.elektrik"); + expect(refineCategory("faturalar", "İGDAŞ")).toBe("faturalar.dogalgaz"); + expect(refineCategory("faturalar", "Vodafone")).toBe("faturalar.telefon"); + expect(refineCategory("ulasim", "Petrol Ofisi")).toBe("ulasim.yakit"); + expect(refineCategory("ulasim", "İstanbulkart")).toBe("ulasim.toplu-tasima"); + expect(refineCategory("saglik", "Yaşam Eczanesi")).toBe("saglik.ilac"); + expect(refineCategory("market.gida", "BİM")).toBe("market.gida"); + expect(bankExpenseCategory("Banka kartı harcaması", "Banka kartı – börekçi")).toBe( + "eglence.kafe", + ); + expect(bankExpenseCategory("KMH faiz ve vergi", "KMH KKDF / BSMV")).toBe("banka.vergi"); + expect(bankExpenseCategory("KMH faiz ve vergi", "KMH faiz tahakkuku")).toBe("banka.faiz"); + expect(bankExpenseCategory("Havale/EFT (kişi)", "Giden havale – yemek")).toBe( + "eglence.disarida-yemek", + ); + }); +}); + +describe("parseCardSheet", () => { + const rows = [ + CARD_HEAD, + [ + serial("2025-12-28"), + serial("2026-01-28"), + "Elma Market", + "Market & gıda", + "Zorunlu", + "Fiziksel kart", + null, + 98, + ], + [ + serial("2025-12-29"), + serial("2026-01-28"), + "Elma Market", + "Market & gıda", + "Zorunlu", + "Fiziksel kart", + null, + 98, + ], + [ + serial("2025-12-29"), + serial("2026-01-28"), + "Elma Market", + "Market & gıda", + "Zorunlu", + "Fiziksel kart", + null, + 98, + ], + [ + serial("2025-12-06"), + serial("2026-01-28"), + "Koçtaş (taksit)", + "Ev / tadilat / yapı market", + "Beklenmeyen", + "Fiziksel kart", + "2/7 (3.465,95 TL)", + 495.14, + ], + [ + serial("2026-01-05"), + serial("2026-01-28"), + "KoçAilem kampanya iadesi", + "Kampanya iadesi", + "İstek", + "Fiziksel kart", + null, + -50, + ], + [ + serial("2026-01-06"), + serial("2026-01-28"), + "Test Esnaf", + "Tanımsız esnaf", + null, + "Fiziksel kart", + null, + 120, + ], + [ + serial("2026-01-07"), + serial("2026-01-28"), + "Simitçi", + "Börek, simit, pastane, büfe", + "İstek", + "Dijital kart", + null, + 35.5, + ], + [null, null, null, null, null, null, null, null], + ]; + const out = parseCardSheet(rows); + it("harcama çıkış (−), iade giriş (+) ve gelir 'İade'; boş satır atlanır", () => { + expect(out).toHaveLength(7); + expect(out[0]).toMatchObject({ + kind: "expense", + amountMinor: -9_800, + categoryKey: "market.gida", + kakeiboGroup: "ZORUNLU", + }); + expect(out[4]).toMatchObject({ + kind: "income", + amountMinor: 5_000, + categoryKey: "iade", + kakeiboGroup: null, + }); + }); + it("aynı gün aynı tutar ayrı anahtar alır; taksit kesim tarihinde, plan anahtarıyla", () => { + expect(out[1]!.key).not.toBe(out[2]!.key); + expect(out[3]).toMatchObject({ + date: "2026-01-28", + categoryKey: "ev", + kakeiboGroup: "BEKLENMEYEN", + installment: { index: 2, count: 7, totalMinor: 346_595 }, + }); + }); + it("tanımsız esnaf grupsuz ve incelemeye düşer", () => { + expect(out[5]).toMatchObject({ + categoryKey: "diger-gider", + kakeiboGroup: null, + review: "ungrouped", + }); + expect(out[6]).toMatchObject({ categoryKey: "eglence.kafe", kakeiboGroup: "ISTEK" }); + }); + it("ekstre kontrolü: satır toplamı (harcama − iade) = ekstredeki harcama", () => { + const total = 98 * 3 + 495.14 - 50 + 120 + 35.5; + const ok = checkStatements(out, new Map([["2026-01-28", Math.round(total * 100)]])); + expect(ok).toEqual([ + { + cut: "2026-01-28", + rowsMinor: Math.round(total * 100), + statementMinor: Math.round(total * 100), + ok: true, + }, + ]); + expect(checkStatements(out, new Map([["2026-01-28", 1]]))[0]!.ok).toBe(false); + }); +}); + +describe("parseBankSheet", () => { + const rows = [ + BANK_HEAD, + [serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null], + [serial("2026-01-02"), "Yol parası ödemesi", "Yol parası", 200, 1200, null], + [serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -500, 700, null], + [ + serial("2026-01-04"), + "Kredi kartı borç ödemesi", + "Kredi kartı ödemesi", + -330.79, + 369.21, + null, + ], + [serial("2026-01-05"), "Banka kartı – börekçi", "Banka kartı harcaması", -45, 324.21, null], + [serial("2026-01-06"), "KMH KKDF / BSMV", "KMH faiz ve vergi", -10, 314.21, null], + [serial("2026-01-07"), "Altın alımı (Birikim hesabı)", "Altın", -100, 214.21, null], + [serial("2026-01-08"), "Maaş promosyonu", "Promosyon", 250, 464.21, null], + [serial("2026-01-08"), "Giden FAST (promosyon)", "Promosyon", -250, 214.21, null], + [serial("2026-01-09"), "Bilinmeyen", "Yeni tür", -14.21, 200, null], + ]; + const b = parseBankSheet(rows); + it("açılış, kapanış ve bakiye zinciri", () => { + expect(b.opening).toEqual({ date: "2025-12-31", balanceMinor: 100_000 }); + expect(b.closingBalanceMinor).toBe(20_000); + expect(b.balanceMismatch).toBeNull(); + }); + it("türler: gelir, transfer (nakit/kart), gider, altın bekler, promosyon çifti atlanır", () => { + const by = (desc: string) => b.rows.find((r) => r.description === desc)!; + expect(by("Yol parası ödemesi")).toMatchObject({ + kind: "income", + categoryKey: "kakeibo.yol", + amountMinor: 20_000, + }); + expect(by("ATM nakit çekim")).toMatchObject({ kind: "transfer", counterRole: "cash" }); + expect(by("Kredi kartı borç ödemesi")).toMatchObject({ kind: "transfer", counterRole: "card" }); + expect(by("Banka kartı – börekçi")).toMatchObject({ + kind: "expense", + categoryKey: "eglence.kafe", + }); + expect(by("KMH KKDF / BSMV")).toMatchObject({ kind: "expense", categoryKey: "banka.vergi" }); + expect(by("Altın alımı (Birikim hesabı)")).toMatchObject({ + kind: "deferred", + counterRole: "gold", + }); + expect( + b.rows.filter((r) => r.source.type === "Promosyon").every((r) => r.kind === "skip"), + ).toBe(true); + expect(by("Bilinmeyen")).toMatchObject({ kind: "expense", review: "unknownType" }); + expect(firstCardPayment(b.rows)).toEqual({ date: "2026-01-04", amountMinor: 33_079 }); + }); + it("bakiye zinciri kırılırsa ilk uyuşmayan satır bildirilir", () => { + const broken = rows.map((r, i) => (i === 3 ? [...r.slice(0, 4), 999, null] : r)); + expect(parseBankSheet(broken).balanceMismatch).toMatchObject({ + line: 3, + expectedMinor: 70_000, + actualMinor: 99_900, + }); + }); +}); + +describe("parseStatementTotals", () => { + it("Özet 1. bölümünden kesim → ekstredeki harcama", () => { + const rows = [ + ["Kakeibo özeti"], + [], + ["1. Kart harcaması"], + [ + "Ekstre kesim", + "Zorunlu", + "İstek", + "Kültür", + "Beklenmeyen", + "Toplam", + "Ekstredeki harcama", + "Fark", + ], + [serial("2026-01-28"), 1, 2, 3, 4, 10, 476.54, 0], + [serial("2026-03-01"), 1, 2, 3, 4, 10, 374.7, 0], + ["Toplam"], + ]; + expect([...parseStatementTotals(rows)]).toEqual([ + ["2026-01-28", 47_654], + ["2026-03-01", 37_470], + ]); + }); +}); diff --git a/src/shared/import/kakeibo-workbook.ts b/src/shared/import/kakeibo-workbook.ts new file mode 100644 index 0000000..8e5ce84 --- /dev/null +++ b/src/shared/import/kakeibo-workbook.ts @@ -0,0 +1,473 @@ +import { isKakeiboGroup, type KakeiboGroup } from "../kakeibo"; + +/** + * Kakeibo çalışma kitabı içe aktarma motoru (Faz 17a). Saf fonksiyonlar; Excel okuma (SheetJS) + * dışarıda yapılır, burada ham satır dizileri ([[hücre, …], …]) işlenir. + * + * Sayfalar: + * "Kredi Kartı": İşlem tarihi | Ekstre kesim | İşyeri | Kategori | Kakeibo | Kart | Taksit | Tutar (TL) + * "Maaş Hesabı": Tarih | Açıklama | Tür | Tutar (TL) | Bakiye (TL) | Ay + * "Özet": 1. bölümde ekstre kesimi başına "Ekstredeki harcama" (doğrulama için). + * + * Tarihler Excel seri numarası (gün) olarak gelir ve gün olarak çözülür (saat dilimi kayması + * yok). Tutarlar kuruşa yuvarlanır. Kart satırında pozitif tutar harcama, negatif tutar iadedir. + */ + +export type ImportRole = "card" | "bank" | "cash" | "extra" | "gold"; +export type RowKind = "expense" | "income" | "transfer" | "skip" | "deferred"; + +/** Yeni oluşturulacak (varsayılan sette olmayan) kategoriler; anahtar `kakeibo.*`. */ +export const IMPORT_CATEGORIES: { + key: string; + kind: "expense" | "income"; + name: string; + icon: string; + color: string; + kakeiboGroup: KakeiboGroup | null; +}[] = [ + { + key: "kakeibo.sigorta", + kind: "expense", + name: "Sigorta", + icon: "heart-pulse", + color: "red", + kakeiboGroup: "ZORUNLU", + }, + { + key: "kakeibo.online", + kind: "expense", + name: "Online alışveriş", + icon: "shopping-basket", + color: "purple", + kakeiboGroup: "ISTEK", + }, + { + key: "kakeibo.elektronik", + kind: "expense", + name: "Elektronik ve hobi", + icon: "gamepad", + color: "indigo", + kakeiboGroup: "ISTEK", + }, + { + key: "kakeibo.kultur", + kind: "expense", + name: "Kitap ve kültür", + icon: "book", + color: "teal", + kakeiboGroup: "KULTUR", + }, + { + key: "kakeibo.prim", + kind: "income", + name: "Prim (maaş farkı)", + icon: "trending-up", + color: "green", + kakeiboGroup: null, + }, + { + key: "kakeibo.yol", + kind: "income", + name: "Yol parası", + icon: "bus", + color: "teal", + kakeiboGroup: null, + }, + { + key: "kakeibo.avans", + kind: "income", + name: "Avans", + icon: "briefcase", + color: "slate", + kakeiboGroup: null, + }, + { + key: "kakeibo.es-katkisi", + kind: "income", + name: "Eş katkısı", + icon: "coins", + color: "pink", + kakeiboGroup: null, + }, + { + key: "kakeibo.aile-destegi", + kind: "income", + name: "Aile desteği", + icon: "gift", + color: "amber", + kakeiboGroup: null, + }, +]; + +export type ProposedRow = { + /** Sayfa ve sıra (1'den, başlık hariç). */ + sheet: "card" | "bank"; + line: number; + date: string; + /** Kart satırında ekstre kesimi. */ + statementCut: string | null; + description: string; + /** İşaretli tutar: hesap açısından giriş (+) / çıkış (−), kuruş. */ + amountMinor: number; + kind: RowKind; + /** Kategori varsayılan anahtarı (gelir/giderde). */ + categoryKey: string | null; + /** Kaynağın Kakeibo grubu (yalnızca giderde; kategorinin grubundan farklıysa kayıtta değiştirilir). */ + kakeiboGroup: KakeiboGroup | null; + /** Transferde karşı hesabın rolü (bank satırında). */ + counterRole: ImportRole | null; + installment: { key: string; index: number; count: number; totalMinor: number } | null; + /** Kaynaktaki ham sınıflandırma (önizlemede gösterilir). */ + source: { category: string; type: string }; + /** Kullanıcının bakması gereken satır (grupsuz, işaret uyuşmazlığı, bilinmeyen tür). */ + review: string | null; + /** Tekrar anahtarı (aynı gün aynı tutar ve açıklamada sıra numarası dahil). */ + key: string; +}; + +// --- Hücre yardımcıları --- + +/** Excel seri günü → "YYYY-MM-DD" (1900 tarih sistemi, 1900-02-29 hatası dahil). */ +export function excelDate(v: unknown): string | null { + if (typeof v === "number" && Number.isFinite(v)) { + const ms = Math.round((v - 25569) * 86_400_000); + const d = new Date(ms); + return d.toISOString().slice(0, 10); + } + if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10); + return null; +} + +export const toMinor = (v: unknown): number | null => + typeof v === "number" && Number.isFinite(v) ? Math.round(v * 100) : null; + +const text = (v: unknown) => (v === null || v === undefined ? "" : String(v).trim()); + +const fold = (s: string) => + s + .toLocaleLowerCase("tr-TR") + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/ı/g, "i") + .replace(/\s+/g, " ") + .trim(); + +// --- Kategori eşleştirme --- + +const CARD_CATEGORY: Record = { + "market & gida": "market.gida", + "borek, simit, pastane, bufe": "eglence.kafe", + "disarida yemek & paket servis": "eglence.disarida-yemek", + "dijital abonelik & yazilim": "abonelikler", + "elektronik, beyaz esya & hobi": "kakeibo.elektronik", + "ev / tadilat / yapi market": "ev", + "faturalar (elektrik, su, gaz, internet, gsm)": "faturalar", + "giyim & ayakkabi": "giyim", + "hayat/kredi sigortasi": "kakeibo.sigorta", + "kitap & kultur & eglence": "kakeibo.kultur", + "kozmetik & kisisel bakim": "market.kisisel-bakim", + "online alisveris": "kakeibo.online", + "saglik (eczane, hastane, doktor)": "saglik", + "tanimsiz esnaf": "diger-gider", + "tatil & seyahat": "eglence.tatil", + "ulasim & yakit": "ulasim", + "cocuk, okul & egitim": "cocuk", + "belediye & resmi": "vergi", +}; + +/** Üst kategoriyi işyerine göre alt kategoriye inceltir (fatura türü, yakıt/toplu taşıma, eczane). */ +export function refineCategory(key: string, merchant: string): string { + const m = fold(merchant); + if (key === "faturalar") { + if (/ck bogazici|enerjisa|bedas|ayedas|elektrik/.test(m)) return "faturalar.elektrik"; + if (/igdas|dogalgaz|dogal gaz/.test(m)) return "faturalar.dogalgaz"; + if (/iski|\bsu\b/.test(m)) return "faturalar.su"; + if (/turknet|superonline|ttnet|turk telekom|internet/.test(m)) return "faturalar.internet"; + if (/vodafone|turkcell|gsm/.test(m)) return "faturalar.telefon"; + } + if (key === "ulasim") { + if (/petrol|opet|shell|\bbp\b|total|akaryakit|yakit/.test(m)) return "ulasim.yakit"; + if (/istanbulkart|taksi|marti|bitaksi|metro|otobus/.test(m)) return "ulasim.toplu-tasima"; + } + if (key === "saglik" && /eczane/.test(m)) return "saglik.ilac"; + return key; +} + +/** Taksit metni: "2/7 (3.465,95 TL)" → sıra, adet, toplam. */ +export function parseInstallment( + v: unknown, +): { index: number; count: number; totalMinor: number } | null { + const m = /^(\d+)\s*\/\s*(\d+)\s*\(([\d.]+),(\d{2})\s*TL\)/.exec(text(v)); + if (!m) return null; + return { + index: Number(m[1]), + count: Number(m[2]), + totalMinor: Number(m[3]!.replace(/\./g, "")) * 100 + Number(m[4]), + }; +} + +const groupOf = (v: unknown): KakeiboGroup | null => { + const g = fold(text(v)); + const map: Record = { + zorunlu: "ZORUNLU", + istek: "ISTEK", + kultur: "KULTUR", + beklenmeyen: "BEKLENMEYEN", + }; + const x = map[g]; + return x && isKakeiboGroup(x) ? x : null; +}; + +/** Aynı gün + tutar + açıklama tekrarlarını ayırmak için sıra numaralı anahtar. */ +function keyer() { + const seen = new Map(); + return (base: string) => { + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + return `${base}#${n}`; + }; +} + +// --- Kart sayfası --- + +export function parseCardSheet(rows: readonly (readonly unknown[])[]): ProposedRow[] { + const out: ProposedRow[] = []; + const key = keyer(); + rows.slice(1).forEach((r, i) => { + const date = excelDate(r[0]); + const amount = toMinor(r[7]); + if (!date || amount === null || amount === 0) return; + const cut = excelDate(r[1]); + const merchant = text(r[2]); + const category = text(r[3]); + const inst = parseInstallment(r[6]); + const refund = amount < 0; + const mapped = CARD_CATEGORY[fold(category)]; + const isCampaign = fold(category) === "kampanya iadesi"; + const group = groupOf(r[4]); + let review: string | null = null; + let categoryKey: string | null; + let kind: RowKind; + if (refund || isCampaign) { + kind = "income"; + categoryKey = "iade"; + } else { + kind = "expense"; + categoryKey = mapped ? refineCategory(mapped, merchant) : "diger-gider"; + if (!mapped) review = "unknownCategory"; + else if (categoryKey === "diger-gider" && !group) review = "ungrouped"; + } + // Taksit: ekstreye yansıdığı kesim tarihinde; plan anahtarı işyeri + toplam + adet. + const effectiveDate = inst && cut ? cut : date; + out.push({ + sheet: "card", + line: i + 1, + date: effectiveDate, + statementCut: cut, + description: merchant, + amountMinor: -amount, + kind, + categoryKey, + kakeiboGroup: kind === "expense" ? group : null, + counterRole: null, + installment: inst + ? { key: `${fold(merchant)}|${inst.totalMinor}|${inst.count}`, ...inst } + : null, + source: { category, type: text(r[5]) }, + review, + key: key(`card|${effectiveDate}|${amount}|${fold(merchant)}`), + }); + }); + return out; +} + +// --- Maaş hesabı sayfası --- + +type BankRule = + | { kind: "income" | "expense"; categoryKey: string } + | { kind: "transfer"; counterRole: ImportRole } + | { kind: "deferred"; reason: string } + | { kind: "opening" } + | { kind: "promo" }; + +const BANK_TYPE: Record = { + acilis: { kind: "opening" }, + maas: { kind: "income", categoryKey: "maas" }, + "prim (maas farki)": { kind: "income", categoryKey: "kakeibo.prim" }, + "yol parasi": { kind: "income", categoryKey: "kakeibo.yol" }, + avans: { kind: "income", categoryKey: "kakeibo.avans" }, + "es katkisi": { kind: "income", categoryKey: "kakeibo.es-katkisi" }, + "aile destegi": { kind: "income", categoryKey: "kakeibo.aile-destegi" }, + "diger giris": { kind: "income", categoryKey: "diger-gelir" }, + promosyon: { kind: "promo" }, + "kredi karti odemesi": { kind: "transfer", counterRole: "card" }, + "nakit cekim (atm)": { kind: "transfer", counterRole: "cash" }, + "hesaplar arasi": { kind: "transfer", counterRole: "extra" }, + altin: { kind: "deferred", reason: "gold" }, + "kmh faiz ve vergi": { kind: "expense", categoryKey: "banka.faiz" }, + "banka ucreti": { kind: "expense", categoryKey: "banka.ucret" }, + "banka karti harcamasi": { kind: "expense", categoryKey: "diger-gider" }, + "havale/eft (kisi)": { kind: "expense", categoryKey: "diger-gider" }, + "ev bakim/onarim": { kind: "expense", categoryKey: "ev" }, + "cocuk ve okul": { kind: "expense", categoryKey: "cocuk" }, + aidat: { kind: "expense", categoryKey: "aidat" }, +}; + +/** Banka kartı ve havale açıklamasından kategori önerisi. */ +export function bankExpenseCategory(type: string, description: string): string { + const d = fold(description); + const t = fold(type); + if (t === "kmh faiz ve vergi") return /kkdf|bsmv/.test(d) ? "banka.vergi" : "banka.faiz"; + if (/borekci|borek|simit|pastane|otomat|kahve|bufe/.test(d)) return "eglence.kafe"; + if (/market/.test(d)) return "market.gida"; + if (/saglik|eczane/.test(d)) return "saglik"; + if (/istanbulkart/.test(d)) return "ulasim.toplu-tasima"; + if (/yemek/.test(d)) return "eglence.disarida-yemek"; + return "diger-gider"; +} + +export type BankSheet = { + rows: ProposedRow[]; + /** Açılış satırının bakiyesi ve tarihi (dosyada varsa). */ + opening: { date: string; balanceMinor: number } | null; + /** Bakiye sütununa göre zincir kontrolü: ilk uyuşmayan satır (yoksa null). */ + balanceMismatch: { line: number; expectedMinor: number; actualMinor: number } | null; + closingBalanceMinor: number | null; +}; + +export function parseBankSheet(rows: readonly (readonly unknown[])[]): BankSheet { + const out: ProposedRow[] = []; + const key = keyer(); + let opening: BankSheet["opening"] = null; + let running: number | null = null; + let mismatch: BankSheet["balanceMismatch"] = null; + let closing: number | null = null; + const parsed = rows + .slice(1) + .map((r, i) => ({ r, line: i + 1 })) + .filter(({ r }) => excelDate(r[0])); + // Aynı tutarda giriş/çıkışı olan promosyon çiftleri net etkisizdir (atlanır). + const promo = parsed.filter(({ r }) => fold(text(r[2])) === "promosyon"); + const promoNet = promo.reduce((a, { r }) => a + (toMinor(r[3]) ?? 0), 0); + for (const { r, line } of parsed) { + const date = excelDate(r[0])!; + const type = text(r[2]); + const desc = text(r[1]); + const amount = toMinor(r[3]); + const balance = toMinor(r[4]); + const rule = BANK_TYPE[fold(type)]; + if (rule?.kind === "opening") { + if (balance !== null) { + opening = { date, balanceMinor: balance }; + running = balance; + } + continue; + } + if (amount === null || amount === 0) continue; + if (running !== null) running += amount; + if (balance !== null) { + if (running !== null && running !== balance && !mismatch) + mismatch = { line, expectedMinor: running, actualMinor: balance }; + running = balance; + closing = balance; + } + let kind: RowKind; + let categoryKey: string | null = null; + let counterRole: ImportRole | null = null; + let review: string | null = null; + if (!rule) { + kind = amount > 0 ? "income" : "expense"; + categoryKey = amount > 0 ? "diger-gelir" : "diger-gider"; + review = "unknownType"; + } else if (rule.kind === "promo") { + if (promoNet === 0) kind = "skip"; + else { + kind = amount > 0 ? "income" : "expense"; + categoryKey = amount > 0 ? "ek-gelir" : "diger-gider"; + review = "promo"; + } + } else if (rule.kind === "transfer") { + kind = "transfer"; + counterRole = rule.counterRole; + } else if (rule.kind === "deferred") { + kind = "deferred"; + counterRole = "gold"; + } else { + kind = rule.kind; + categoryKey = + rule.kind === "expense" && (rule.categoryKey === "diger-gider" || type.startsWith("KMH")) + ? bankExpenseCategory(type, desc) + : rule.categoryKey; + // Yön uyuşmazlığı (ör. gider türünde giriş): kullanıcı baksın. + if ((rule.kind === "income") !== amount > 0) review = "signMismatch"; + else if (categoryKey === "diger-gider") review = "ungrouped"; + } + out.push({ + sheet: "bank", + line, + date, + statementCut: null, + description: desc, + amountMinor: amount, + kind, + categoryKey, + kakeiboGroup: null, + counterRole, + installment: null, + source: { category: "", type }, + review, + key: key(`bank|${date}|${amount}|${fold(desc)}`), + }); + } + return { rows: out, opening, balanceMismatch: mismatch, closingBalanceMinor: closing }; +} + +// --- Özet: ekstre toplamları --- + +/** "Özet" sayfasının 1. bölümünden ekstre kesimi → ekstredeki harcama (kuruş). */ +export function parseStatementTotals(rows: readonly (readonly unknown[])[]): Map { + const out = new Map(); + const start = rows.findIndex((r) => fold(text(r[0])) === "ekstre kesim"); + if (start < 0) return out; + for (const r of rows.slice(start + 1)) { + const cut = excelDate(r[0]); + if (!cut) break; + const total = toMinor(r[6]); + if (total !== null) out.set(cut, total); + } + return out; +} + +export type StatementCheck = { + cut: string; + rowsMinor: number; + statementMinor: number | null; + ok: boolean; +}; + +/** Her ekstre için satır toplamı (harcama − iade) = ekstredeki harcama. */ +export function checkStatements( + rows: readonly ProposedRow[], + totals: ReadonlyMap, +): StatementCheck[] { + const sums = new Map(); + for (const r of rows) + if (r.sheet === "card" && r.statementCut) + sums.set(r.statementCut, (sums.get(r.statementCut) ?? 0) - r.amountMinor); + return [...sums.entries()] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([cut, rowsMinor]) => { + const statementMinor = totals.get(cut) ?? null; + return { cut, rowsMinor, statementMinor, ok: statementMinor === rowsMinor }; + }); +} + +/** Banka hesabından karta yapılan ilk ödeme: kartın açılış borcu önerisi (kuruş, pozitif). */ +export function firstCardPayment( + rows: readonly ProposedRow[], +): { date: string; amountMinor: number } | null { + const r = rows.find( + (x) => x.sheet === "bank" && x.kind === "transfer" && x.counterRole === "card", + ); + return r ? { date: r.date, amountMinor: -r.amountMinor } : null; +}