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 m015 } from "./015-shopping-lists";
|
||||
import { migration as m016 } from "./016-kakeibo-groups";
|
||||
import { migration as m017 } from "./017-imports";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -36,6 +37,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
m014,
|
||||
m015,
|
||||
m016,
|
||||
m017,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
@@ -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:
|
||||
"İşlem listeden ve bakiyelerden kaldırılır. Bildirimdeki “Geri al” ile geri getirebilirsiniz.",
|
||||
export: "CSV indir",
|
||||
import: "İçe aktar",
|
||||
csv: {
|
||||
header: [
|
||||
"İşlem No",
|
||||
@@ -1862,6 +1863,145 @@ export const tr = {
|
||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||
passwordRequired: "Şifre girin",
|
||||
},
|
||||
imports: {
|
||||
title: "Ekstre içe aktarma",
|
||||
description:
|
||||
"Kakeibo çalışma kitabından kredi kartı ve maaş hesabı hareketlerini önizleyerek aktarın. Aynı satır iki kez aktarılmaz; işlenen aktarma tek tuşla geri alınır.",
|
||||
tag: "içe-aktarma",
|
||||
upload: "Dosya yükle",
|
||||
uploadHint:
|
||||
'Kakeibo çalışma kitabı (.xlsx): "Kredi Kartı" ve "Maaş Hesabı" sayfaları, "Özet" ekstre kontrolü için.',
|
||||
uploading: "Okunuyor…",
|
||||
history: "Aktarmalar",
|
||||
empty: "Henüz aktarma yok.",
|
||||
open: "Aç",
|
||||
statuses: { draft: "Taslak", committed: "İşlendi", reverted: "Geri alındı" } as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
rowsCount: (n: number) => `${n} satır`,
|
||||
back: "Ekstre içe aktarma",
|
||||
steps: {
|
||||
accounts: "1. Hesaplar",
|
||||
checks: "2. Kontroller",
|
||||
rows: "3. Önizleme",
|
||||
commit: "4. İşle",
|
||||
},
|
||||
roles: {
|
||||
card: "Kredi kartı",
|
||||
bank: "Maaş hesabı (KMH)",
|
||||
cash: "Nakit",
|
||||
extra: "Ek hesap",
|
||||
gold: "Altın hesabı",
|
||||
} as Record<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: {
|
||||
groups: {
|
||||
ZORUNLU: "Zorunlu",
|
||||
|
||||
@@ -46,6 +46,8 @@ export const AUDIT_ACTIONS = [
|
||||
"product.unarchive",
|
||||
"price.delete",
|
||||
"shoppingList.delete",
|
||||
"import.commit",
|
||||
"import.revert",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -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: [] },
|
||||
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
||||
isAdjustment: { type: Boolean, default: false },
|
||||
/** Ekstre içe aktarmasından geldiyse parti ve tekrar engeli anahtarı (Faz 17). */
|
||||
importBatchId: { type: Schema.Types.ObjectId, default: null },
|
||||
importKey: { type: String, default: null },
|
||||
/** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
|
||||
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
@@ -92,6 +95,15 @@ const transactionSchema = new Schema(
|
||||
{ timestamps: true, collection: "transactions" },
|
||||
);
|
||||
|
||||
// Aynı ekstre satırı iki kez içe aktarılamaz. Geri alınan partide anahtar boşaltılır (yeniden
|
||||
// içe aktarılabilir).
|
||||
transactionSchema.index(
|
||||
{ householdId: 1, importKey: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { importKey: { $type: "string" } },
|
||||
},
|
||||
);
|
||||
transactionSchema.index({ householdId: 1, deletedAt: 1, date: -1, createdAt: -1 });
|
||||
transactionSchema.index({ householdId: 1, status: 1, date: 1 });
|
||||
transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
||||
|
||||
@@ -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