feat(import): Kakeibo çalışma kitabı için içe aktarma motoru ve servis

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 09:31:44 +03:00
parent c52e8f9976
commit ebcdf3f741
11 changed files with 2195 additions and 0 deletions
+19
View File
@@ -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(() => {});
},
};
+2
View File
@@ -16,6 +16,7 @@ import { migration as m013 } from "./013-stores-products";
import { migration as m014 } from "./014-product-prices"; import { migration as m014 } from "./014-product-prices";
import { migration as m015 } from "./015-shopping-lists"; import { migration as m015 } from "./015-shopping-lists";
import { migration as m016 } from "./016-kakeibo-groups"; import { migration as m016 } from "./016-kakeibo-groups";
import { migration as m017 } from "./017-imports";
import type { Migration } from "./types"; import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
@@ -36,6 +37,7 @@ export const MIGRATIONS: Migration[] = [
m014, m014,
m015, m015,
m016, m016,
m017,
]; ];
const COLLECTION = "migrations"; const COLLECTION = "migrations";
+126
View File
@@ -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<T>(r: ActionResult<T>) {
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));
}
+264
View File
@@ -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<ReturnType<typeof setupSeededDb>>;
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<string, string> = {};
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);
});
});
+816
View File
@@ -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<ImportRole, AccountType[]> = {
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<Record<ImportRole, string>>;
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<Record<ImportRole, string>>;
/** Satırların ihtiyaç duyduğu roller. */
neededRoles: ImportRole[];
accountOptions: { id: string; name: string; type: AccountType }[];
presets: Record<string, AccountPreset>;
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<CatLean[]>();
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<string, CatLean>) =>
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<string, CatLean>) =>
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<ImportRole>();
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<BatchDoc>();
}
// --- Okuma ---
export async function listImports(
userId: string,
householdId: string,
): Promise<ImportSummary[] | null> {
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<BatchDoc, "rows"> & { 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<ImportDraft | null> {
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<string, AccountPreset> = {
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult<{ id: string }>> {
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<ActionResult<{ updated: number }>> {
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<string, unknown> = { "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<ActionResult<{ transactions: number; skipped: number }>> {
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<Record<ImportRole, Types.ObjectId>> = {};
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<CatLean[]>();
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<string, Types.ObjectId>();
const docs: Record<string, unknown>[] = [];
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<never>) {
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<ActionResult<{ transactions: number }>> {
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<ActionResult> {
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);
}
+140
View File
@@ -340,6 +340,7 @@ export const tr = {
deleteDescription: deleteDescription:
"İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.", "İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.",
export: "CSV indir", export: "CSV indir",
import: "İçe aktar",
csv: { csv: {
header: [ header: [
"İşlem No", "İşlem No",
@@ -1862,6 +1863,145 @@ export const tr = {
identifierRequired: "Kullanıcı adı veya e-posta girin", identifierRequired: "Kullanıcı adı veya e-posta girin",
passwordRequired: "Şifre 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<string, string>,
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<string, string>,
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<string, string>,
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<string, string>,
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: { kakeibo: {
groups: { groups: {
ZORUNLU: "Zorunlu", ZORUNLU: "Zorunlu",
+2
View File
@@ -46,6 +46,8 @@ export const AUDIT_ACTIONS = [
"product.unarchive", "product.unarchive",
"price.delete", "price.delete",
"shoppingList.delete", "shoppingList.delete",
"import.commit",
"import.revert",
] as const; ] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number]; export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+64
View File
@@ -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<typeof importBatchSchema>;
export const ImportBatchModel: Model<ImportBatch> =
(models.ImportBatch as Model<ImportBatch> | undefined) ??
model<ImportBatch>("ImportBatch", importBatchSchema);
+12
View File
@@ -81,6 +81,9 @@ const transactionSchema = new Schema(
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] }, reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */ /** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
isAdjustment: { type: Boolean, default: false }, 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). */ /** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
shoppingListId: { type: Schema.Types.ObjectId, default: null }, shoppingListId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null }, deletedAt: { type: Date, default: null },
@@ -92,6 +95,15 @@ const transactionSchema = new Schema(
{ timestamps: true, collection: "transactions" }, { 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, deletedAt: 1, date: -1, createdAt: -1 });
transactionSchema.index({ householdId: 1, status: 1, date: 1 }); transactionSchema.index({ householdId: 1, status: 1, date: 1 });
transactionSchema.index({ householdId: 1, accountId: 1, date: -1 }); transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
+277
View File
@@ -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],
]);
});
});
+473
View File
@@ -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<string, string> = {
"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<string, KakeiboGroup> = {
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<string, number>();
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<string, BankRule> = {
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<string, number> {
const out = new Map<string, number>();
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<string, number>,
): StatementCheck[] {
const sums = new Map<string, number>();
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;
}