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