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,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(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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