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:
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user