feat: Faz 17a - Kakeibo K2, ekstre içe aktarma altyapısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 09:31:50 +03:00
21 changed files with 3064 additions and 1 deletions
+19
View File
@@ -6,6 +6,25 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
Faz 17a: Kakeibo K2 — ekstre içe aktarma altyapısı.
### Eklendi
- **Ekstre içe aktarma** (`/islemler/ice-aktar`, İşlemler sayfasında "İçe aktar"): Kakeibo çalışma kitabı (.xlsx; "Kredi Kartı" ve "Maaş Hesabı" sayfaları) yüklenir, önce taslak olarak önizlenir, sonra tek seferde işlenir.
- Kurulum adımı: dosyanın ihtiyaç duyduğu hesaplar (kredi kartı, maaş hesabı, nakit, ek hesap) mevcut hesaplardan seçilir ya da önerilen açılış bakiyesiyle tek tıkla oluşturulur (açılış bakiyesi dosyadaki devreden bakiyeden, kart borcu ilk kart ödemesinden).
- Kontroller: her ekstre için **ekstre toplamı = satır toplamı**, maaş hesabı bakiye zinciri ve son bakiye, daha önce aktarılmış satırlar, bekletilen altın satırları ve oluşturulacak kategoriler.
- Önizleme: satır başına tür (gider / gelir / atla), kategori ve Kakeibo grubu düzenlenebilir; "aynı açıklamalı tüm satırlara uygula"; İnceleme gereken / Grupsuz / Transfer / Atlanacak süzgeçleri.
- Muhasebe kuralları: kart ödemesi, ATM çekimi ve hesaplar arası havale transfer (çift sayma yok); kampanya iadesi ve iadeler gelir ("İade"); KMH faizi, KKDF/BSMV ve banka ücretleri banka maliyeti; taksitler ekstreye yansıdığı kesim tarihinde, ortak taksit planıyla; aynı gün çıkan promosyon çifti atlanır.
- Tekrar engeli: her satırın parmak izi saklanır; aynı dosya yeniden yüklendiğinde aktarılmış satırlar atlanır. **Toplu geri alma** aktarmanın tüm işlemlerini siler ve dosya yeniden aktarılabilir hale gelir.
- Eksik Kakeibo kategorileri (Sigorta, Online alışveriş, Elektronik ve hobi, Kitap ve kültür; Prim, Yol parası, Avans, Eş katkısı, Aile desteği) işlenirken oluşturulur.
- Migration `017-imports`: aktarma partileri ve işlem parmak izi dizinleri (geri alınabilir).
- Testler: çalışma kitabı ayrıştırma, taksit metni, ekstre ve bakiye kontrolleri (birim); taslak, hesap oluşturma, toplu düzenleme, işleme, bakiyeler, tekrar yükleme ve geri alma (entegrasyon).
### Bilinen sınırlamalar
- Altın alım/satım satırları Faz 17b'de altın hesabı dosyasıyla eşleştirilene dek bekletilir; o zamana dek maaş hesabı bakiyesi bu satırların neti kadar farklı görünür.
- Yapı Kredi "Güncel Dönem" (.xls) ve Ek Hesap / Altın Hesabı (PDF) dosyaları Faz 17b'de.
## [1.1.0] - 2026-11-07
Faz 16: Kakeibo K1 — dört harcama grubu.
+2
View File
@@ -11,6 +11,8 @@ FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
# SheetJS resmi dağıtımdan vendor/ altında (npm uzak tarball indirmez).
COPY vendor ./vendor
RUN npm ci --no-audit --no-fund
# 2) Build
+13
View File
@@ -31,6 +31,7 @@
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "^4.6.7"
},
"devDependencies": {
@@ -13158,6 +13159,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "file:vendor/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -51,6 +51,7 @@
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "^4.6.7"
},
"devDependencies": {
+19
View File
@@ -0,0 +1,19 @@
import { ImportBatchModel } from "@/models/import-batch";
import { TransactionModel } from "@/models/transaction";
import type { Migration } from "./types";
/** Ekstre içe aktarma (Faz 17): importBatches index'i ve işlemlerde tekrar engeli index'i. */
export const migration: Migration = {
name: "017-imports",
description: "içe aktarma partileri ve işlemlerde tekrar engeli (importKey) index'leri",
async up() {
await ImportBatchModel.createIndexes();
await TransactionModel.createIndexes();
},
async down(db) {
await db
.collection("transactions")
.dropIndex("householdId_1_importKey_1")
.catch(() => {});
},
};
+2
View File
@@ -16,6 +16,7 @@ import { migration as m013 } from "./013-stores-products";
import { migration as m014 } from "./014-product-prices";
import { migration as 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,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ImportWizard } from "@/features/imports/components/import-wizard";
import { getImportDraft } from "@/features/imports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.imports.title };
/** /islemler/ice-aktar/:id — aktarma taslağı ya da işlenmiş aktarma. */
export default async function ImportPage({ params }: PageProps<"/islemler/ice-aktar/[id]">) {
const { user, household } = await requireActiveHousehold("member");
const { id } = await params;
const draft = await getImportDraft(user.id, household.id, id);
if (!draft) notFound();
return <ImportWizard key={`${draft.id}-${draft.status}`} draft={draft} />;
}
+67
View File
@@ -0,0 +1,67 @@
import { ArrowLeft, ChevronRight } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { UploadCard } from "@/features/imports/components/upload-card";
import { listImports } from "@/features/imports/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { formatDateTime } from "@/shared/format";
const t = tr.imports;
export const metadata: Metadata = { title: t.title };
/** /islemler/ice-aktar — Kakeibo çalışma kitabı yükleme ve aktarma geçmişi. */
export default async function ImportsPage() {
const { user, household } = await requireActiveHousehold("member");
const list = await listImports(user.id, household.id);
if (!list) notFound();
return (
<>
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/islemler">
<ArrowLeft />
{tr.nav.transactions}
</Link>
</Button>
<PageHeader title={t.title} description={t.description} />
<UploadCard />
<section aria-labelledby="imp-h" className="flex flex-col gap-2">
<h2 id="imp-h" className="text-sm font-medium text-muted-foreground">
{t.history}
</h2>
{list.length ? (
<ul className="divide-y rounded-xl border bg-card">
{list.map((b) => (
<li key={b.id}>
<Link
href={`/islemler/ice-aktar/${b.id}`}
className="flex items-center gap-3 p-3 text-sm hover:bg-accent/40"
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{b.fileName}</span>
<span className="block text-xs text-muted-foreground">
{formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)}
{b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""}
</span>
</span>
<Badge variant={b.status === "committed" ? "default" : "secondary"}>
{t.statuses[b.status]}
</Badge>
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
</Link>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
)}
</section>
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getHouseholdActionContext } from "@/lib/dal";
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
import { parseMoney } from "@/shared/money";
import {
commitImport,
createImportAccount,
createImportDraft,
deleteImportDraft,
MAX_IMPORT_BYTES,
revertImport,
setImportAccount,
updateImportRow,
} from "./service";
const t = tr.imports;
const id = z.string().regex(/^[a-f0-9]{24}$/i);
const role = z.enum(["card", "bank", "cash", "extra"]);
async function ctx() {
return getHouseholdActionContext("member");
}
function done<T>(r: ActionResult<T>) {
if (r.ok) revalidatePath("/", "layout");
return r;
}
/** Dosya yükleme (FormData: file). Yalnızca .xlsx; boyut sınırı Server Action gövdesiyle uyumlu. */
export async function uploadImportAction(form: FormData) {
const c = await ctx();
if (!c) return fail(tr.common.unauthorized);
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) return fail(t.errors.noFile);
if (file.size > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
if (!/\.xlsx$/i.test(file.name)) return fail(t.errors.unreadable);
const buffer = Buffer.from(await file.arrayBuffer());
return done(await createImportDraft(c.user.id, c.household.id, { name: file.name, buffer }));
}
export async function setImportAccountAction(batchId: unknown, r: unknown, accountId: unknown) {
const c = await ctx();
const p = z
.object({ batchId: id, role, accountId: id })
.safeParse({ batchId, role: r, accountId });
if (!c || !p.success) return fail(tr.common.notFound);
return done(
await setImportAccount(
c.user.id,
c.household.id,
p.data.batchId,
p.data.role,
p.data.accountId,
),
);
}
export async function createImportAccountAction(batchId: unknown, r: unknown, input: unknown) {
const c = await ctx();
const p = z
.object({
batchId: id,
role,
name: z.string().trim().min(1, tr.common.required).max(80),
opening: z.string().trim(),
openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
.safeParse({ batchId, role: r, ...(input as object) });
if (!c || !p.success) return fail(tr.adminUsers.errors.checkFields);
const minor = p.data.opening ? parseMoney(p.data.opening.replace("−", "-")) : 0;
if (minor === null) return fail(tr.validation.money);
return done(
await createImportAccount(c.user.id, c.household.id, p.data.batchId, p.data.role, {
name: p.data.name,
openingMinor: minor,
openingDate: p.data.openingDate,
}),
);
}
export async function updateImportRowAction(batchId: unknown, edit: unknown) {
const c = await ctx();
const p = z
.object({
batchId: id,
rowId: id,
kind: z.enum(["expense", "income", "skip"]).optional(),
categoryId: z
.union([id, z.string().regex(/^new:[a-z.-]+$/)])
.nullable()
.optional(),
kakeiboGroup: z.enum(KAKEIBO_GROUPS).nullable().optional(),
applyToSame: z.boolean().optional(),
})
.safeParse({ batchId, ...(edit as object) });
if (!c || !p.success) return fail(tr.common.notFound);
const { batchId: b, ...rest } = p.data;
// Satır düzenlemesi sık; tüm sayfayı yenilemeye gerek yok.
return updateImportRow(c.user.id, c.household.id, b, rest);
}
export async function commitImportAction(batchId: unknown) {
const c = await ctx();
const p = id.safeParse(batchId);
if (!c || !p.success) return fail(tr.common.notFound);
return done(await commitImport(c.user.id, c.household.id, p.data));
}
export async function revertImportAction(batchId: unknown) {
const c = await ctx();
const p = id.safeParse(batchId);
if (!c || !p.success) return fail(tr.common.notFound);
return done(await revertImport(c.user.id, c.household.id, p.data));
}
export async function deleteImportDraftAction(batchId: unknown) {
const c = await ctx();
const p = id.safeParse(batchId);
if (!c || !p.success) return fail(tr.common.notFound);
return done(await deleteImportDraft(c.user.id, c.household.id, p.data));
}
@@ -0,0 +1,678 @@
"use client";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
Loader2,
Plus,
Undo2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { PageHeader } from "@/components/page-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { KakeiboSelect } from "@/features/transactions/components/kakeibo-select";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import type { AccountType } from "@/shared/accounts";
import { formatDate, formatMoney } from "@/shared/format";
import type { ImportRole } from "@/shared/import/kakeibo-workbook";
import type { KakeiboGroup } from "@/shared/kakeibo";
import { formatMoneyInput } from "@/shared/money";
import {
commitImportAction,
createImportAccountAction,
deleteImportDraftAction,
revertImportAction,
setImportAccountAction,
updateImportRowAction,
} from "../actions";
import type { ImportDraft, ImportRowView } from "../service";
const t = tr.imports;
const PAGE = 50;
const ROLE_TYPES: Record<string, AccountType[]> = {
card: ["credit_card"],
bank: ["bank"],
cash: ["cash"],
extra: ["bank", "cash"],
};
type Filter = "all" | "review" | "ungrouped" | "transfer" | "skip";
const day = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
function Step({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<h2 className="font-medium">{title}</h2>
{children}
</section>
);
}
function RoleRow({ draft, role }: { draft: ImportDraft; role: ImportRole }) {
const router = useRouter();
const preset = draft.presets[role];
const current = draft.accounts[role] ?? "";
const options = draft.accountOptions.filter((a) => ROLE_TYPES[role]?.includes(a.type));
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState(false);
const isCard = role === "card";
const [v, setV] = useState({
name: preset?.name ?? "",
opening: preset ? formatMoneyInput(Math.abs(preset.openingMinor)) : "",
sign: (preset?.openingMinor ?? 0) < 0 ? "-" : "",
openingDate: preset?.openingDate ?? "",
});
async function pick(accountId: string) {
const r = await setImportAccountAction(draft.id, role, accountId);
if (!r.ok) toast.error(r.error);
else router.refresh();
}
async function create() {
setBusy(true);
const r = await createImportAccountAction(draft.id, role, {
name: v.name,
opening: `${v.sign}${v.opening}`,
openingDate: v.openingDate,
});
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
setCreating(false);
router.refresh();
}
}
return (
<li className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 text-sm font-medium">
{current ? (
<CheckCircle2 className="size-4 text-income" aria-hidden />
) : (
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
)}
{t.roles[role]}
</p>
<p className="text-xs text-muted-foreground">{t.roleHints[role]}</p>
</div>
<Select value={current || undefined} onValueChange={pick}>
<SelectTrigger className="w-56" aria-label={t.roles[role]}>
<SelectValue placeholder={t.selectAccount} />
</SelectTrigger>
<SelectContent>
{options.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{draft.canCreateAccounts && preset && !current ? (
<Button size="sm" variant="outline" onClick={() => setCreating((x) => !x)}>
<Plus />
{t.createSuggested}
</Button>
) : null}
</div>
{creating && preset ? (
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor={`acc-${role}-name`}>{t.accountName}</FieldLabel>
<Input
id={`acc-${role}-name`}
value={v.name}
onChange={(e) => setV((s) => ({ ...s, name: e.target.value }))}
/>
</Field>
<Field>
<FieldLabel htmlFor={`acc-${role}-open`}>
{isCard ? t.openingDebt : t.openingBalance}
</FieldLabel>
<div className="flex gap-1">
{!isCard ? (
<Button
type="button"
variant="outline"
size="icon"
aria-label="±"
onClick={() => setV((s) => ({ ...s, sign: s.sign ? "" : "-" }))}
>
{v.sign ? "−" : "+"}
</Button>
) : null}
<Input
id={`acc-${role}-open`}
inputMode="decimal"
value={v.opening}
onChange={(e) => setV((s) => ({ ...s, opening: e.target.value }))}
className="text-right tabular-nums"
/>
</div>
</Field>
<Field>
<FieldLabel htmlFor={`acc-${role}-date`}>{t.openingDate}</FieldLabel>
<Input
id={`acc-${role}-date`}
type="date"
value={v.openingDate}
onChange={(e) => setV((s) => ({ ...s, openingDate: e.target.value }))}
/>
</Field>
<FieldDescription className="sm:col-span-2">
{isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""}
</FieldDescription>
<Button className="sm:justify-self-end" onClick={create} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.create}
</Button>
</div>
) : null}
</li>
);
}
function RowItem({
row,
draft,
onEdit,
}: {
row: ImportRowView;
draft: ImportDraft;
onEdit: (
row: ImportRowView,
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
) => void;
}) {
const cats = draft.categories.filter(
(c) => c.kind === (row.kind === "income" ? "income" : "expense"),
);
const cat = draft.categories.find((c) => c.id === row.categoryId);
const editable =
!row.duplicate && (row.kind === "expense" || row.kind === "income" || row.kind === "skip");
const muted = row.duplicate || row.kind === "skip" || row.kind === "deferred";
return (
<li
className={cn(
"flex flex-col gap-2 p-3 text-sm",
muted && "bg-muted/30 text-muted-foreground",
)}
>
<div className="flex items-start gap-3">
<span className="w-20 shrink-0 tabular-nums">{day(row.date)}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{row.description || "—"}</span>
<span className="mt-0.5 flex flex-wrap gap-1">
{row.installment ? (
<Badge variant="outline">
{t.installment(row.installment.index, row.installment.count)}
</Badge>
) : null}
{row.duplicate ? (
<Badge variant="secondary" className="gap-1">
<Copy className="size-3" aria-hidden />
{t.duplicate}
</Badge>
) : null}
{row.review && !row.duplicate ? (
<Badge
variant="outline"
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
>
<AlertTriangle className="size-3" aria-hidden />
{t.reviews[row.review] ?? row.review}
</Badge>
) : null}
{row.source.category || row.source.type ? (
<span className="text-xs text-muted-foreground">
{row.source.category || row.source.type}
</span>
) : null}
</span>
</span>
<span
className={cn(
"shrink-0 text-right font-medium tabular-nums",
row.amountMinor < 0 ? "" : "text-income",
)}
>
{row.amountMinor < 0 ? "−" : "+"}
{formatMoney(Math.abs(row.amountMinor))}
</span>
</div>
{row.kind === "transfer" ? (
<p className="pl-23 text-xs">
{t.kinds.transfer}{" "}
{row.amountMinor < 0
? t.transferTo(t.roles[row.counterRole ?? ""] ?? "")
: t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")}
</p>
) : row.kind === "deferred" ? (
<p className="pl-23 text-xs">
{t.kinds.deferred}: {t.roles.gold}
</p>
) : editable ? (
<div className="grid gap-2 sm:grid-cols-[8rem_minmax(0,1fr)_12rem] sm:pl-23">
<Select
value={row.kind}
onValueChange={(k) => onEdit(row, { kind: k as ImportRowView["kind"] })}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.kind}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["expense", "income", "skip"] as const).map((k) => (
<SelectItem key={k} value={k}>
{t.kinds[k]}
</SelectItem>
))}
</SelectContent>
</Select>
{row.kind !== "skip" ? (
<Select
value={row.categoryId ?? undefined}
onValueChange={(c) => onEdit(row, { categoryId: c })}
>
<SelectTrigger className="h-8 w-full text-sm" aria-label={t.columns.category}>
<SelectValue placeholder={tr.transactions.fields.selectCategory} />
</SelectTrigger>
<SelectContent className="max-h-72">
{cats.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.path}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span />
)}
{row.kind === "expense" ? (
<KakeiboSelect
compact
value={row.kakeiboGroup && row.kakeiboGroup !== cat?.group ? row.kakeiboGroup : ""}
onChange={(g) =>
onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null })
}
suggested={cat?.group ?? null}
bankCost={cat?.bankCost ?? false}
ariaLabel={t.columns.group}
/>
) : null}
</div>
) : null}
</li>
);
}
/** /islemler/ice-aktar/:id — hesaplar, kontroller, önizleme ve işleme. */
export function ImportWizard({ draft }: { draft: ImportDraft }) {
const router = useRouter();
const [rows, setRows] = useState(draft.rows);
const [sheet, setSheet] = useState<"card" | "bank">(
draft.rows.some((r) => r.sheet === "card") ? "card" : "bank",
);
const [filter, setFilter] = useState<Filter>("all");
const [page, setPage] = useState(0);
const [applyToSame, setApplyToSame] = useState(true);
const [busy, setBusy] = useState(false);
const [confirmRevert, setConfirmRevert] = useState(false);
const isDraft = draft.status === "draft";
const catOf = (id: string | null) => draft.categories.find((c) => c.id === id);
const visible = useMemo(() => {
const ofSheet = rows.filter((r) => r.sheet === sheet);
switch (filter) {
case "review":
return ofSheet.filter((r) => r.review && !r.duplicate);
case "ungrouped":
return ofSheet.filter(
(r) =>
r.kind === "expense" &&
!r.duplicate &&
!(r.kakeiboGroup ?? catOf(r.categoryId)?.group) &&
!catOf(r.categoryId)?.bankCost,
);
case "transfer":
return ofSheet.filter((r) => r.kind === "transfer");
case "skip":
return ofSheet.filter((r) => r.kind === "skip" || r.kind === "deferred" || r.duplicate);
default:
return ofSheet;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, sheet, filter]);
const pageRows = visible.slice(page * PAGE, page * PAGE + PAGE);
const toCreate = rows.filter((r) => !r.duplicate && r.kind !== "skip" && r.kind !== "deferred");
const missingCategory = toCreate.filter(
(r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId,
);
const missingRoles = draft.neededRoles.filter((r) => !draft.accounts[r]);
const deferredRows = rows.filter((r) => r.kind === "deferred");
const deferred = deferredRows.length;
const deferredNet = deferredRows.reduce((sum, r) => sum + r.amountMinor, 0);
const duplicates = rows.filter((r) => r.duplicate).length;
const okStatements = draft.checks.statements.filter((s) => s.ok).length;
async function edit(
row: ImportRowView,
patch: Partial<Pick<ImportRowView, "kind" | "categoryId" | "kakeiboGroup">>,
) {
const same = (x: ImportRowView) =>
applyToSame
? x.sheet === row.sheet &&
x.description.toLocaleLowerCase("tr-TR") === row.description.toLocaleLowerCase("tr-TR") &&
x.kind === row.kind
: x.id === row.id;
const before = rows;
setRows((rs) => rs.map((x) => (same(x) ? { ...x, ...patch, edited: true } : x)));
const r = await updateImportRowAction(draft.id, { rowId: row.id, ...patch, applyToSame });
if (!r.ok) {
setRows(before);
toast.error(r.error);
}
}
async function commit() {
setBusy(true);
const r = await commitImportAction(draft.id);
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
toast.success(t.committed(r.data.transactions));
router.refresh();
}
}
async function revert() {
setConfirmRevert(false);
setBusy(true);
const r = await revertImportAction(draft.id);
setBusy(false);
if (!r.ok) toast.error(r.error);
else {
toast.success(t.reverted(r.data.transactions));
router.refresh();
}
}
async function removeDraft() {
const r = await deleteImportDraftAction(draft.id);
if (!r.ok) toast.error(r.error);
else router.push("/islemler/ice-aktar");
}
const filters: Filter[] = ["all", "review", "ungrouped", "transfer", "skip"];
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/islemler/ice-aktar">
<ArrowLeft />
{t.back}
</Link>
</Button>
<PageHeader
title={draft.fileName}
description={
<>
{t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)}
</>
}
/>
{!isDraft ? (
<Step title={t.statuses[draft.status]!}>
{draft.committed ? (
<p className="text-sm">
{t.committedSummary(
draft.committed.transactions,
draft.committed.skipped,
draft.committed.categoriesCreated,
)}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/islemler">{t.goTransactions}</Link>
</Button>
{draft.status === "committed" ? (
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmRevert(true)}
disabled={busy}
>
<Undo2 />
{t.revert}
</Button>
) : null}
</div>
</Step>
) : null}
{isDraft && draft.neededRoles.length ? (
<Step title={t.steps.accounts}>
{!draft.canCreateAccounts ? (
<p className="text-xs text-muted-foreground">{t.ownerOnly}</p>
) : null}
<ul className="divide-y">
{draft.neededRoles.map((role) => (
<RoleRow key={role} draft={draft} role={role} />
))}
</ul>
</Step>
) : null}
<Step title={t.steps.checks}>
<ul className="flex flex-col gap-1.5 text-sm">
{draft.checks.statements.length ? (
<li className="flex items-start gap-2">
{okStatements === draft.checks.statements.length ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
) : (
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
)}
<span>
{t.statementsSummary(okStatements, draft.checks.statements.length)}
{draft.checks.statements
.filter((s) => !s.ok)
.map((s) => (
<span key={s.cut} className="block text-xs text-expense">
{t.statementBad(
day(s.cut),
formatMoney(s.rowsMinor),
s.statementMinor === null ? "—" : formatMoney(s.statementMinor),
)}
</span>
))}
</span>
</li>
) : (
<li className="text-muted-foreground">{t.noStatements}</li>
)}
{draft.checks.balance.closingMinor !== null ? (
<li className="flex items-start gap-2">
{draft.checks.balance.mismatchLine === null ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-income" aria-hidden />
) : (
<XCircle className="mt-0.5 size-4 shrink-0 text-expense" aria-hidden />
)}
{draft.checks.balance.mismatchLine === null
? t.balanceOk(formatMoney(draft.checks.balance.closingMinor))
: t.balanceBad(draft.checks.balance.mismatchLine)}
</li>
) : null}
{duplicates ? (
<li className="text-muted-foreground">{t.duplicates(duplicates)}</li>
) : null}
{deferred ? (
<li className="text-muted-foreground">
{t.deferred(
deferred,
`${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`,
)}
</li>
) : null}
{isDraft && draft.newCategories.length ? (
<li className="text-muted-foreground">
{t.newCategories(draft.newCategories.join(", "))}
</li>
) : null}
</ul>
</Step>
<Step title={t.steps.rows}>
<div className="flex flex-wrap items-center gap-2">
<Tabs
value={sheet}
onValueChange={(x) => {
setSheet(x as "card" | "bank");
setPage(0);
}}
>
<TabsList>
{(["card", "bank"] as const)
.filter((s) => rows.some((r) => r.sheet === s))
.map((s) => (
<TabsTrigger key={s} value={s}>
{t.sheets[s]} ({rows.filter((r) => r.sheet === s).length})
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-wrap gap-1">
{filters.map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "secondary" : "ghost"}
onClick={() => {
setFilter(f);
setPage(0);
}}
>
{t.filters[f]}
</Button>
))}
</div>
{isDraft ? (
<label className="ml-auto flex items-center gap-2 text-sm">
<Checkbox checked={applyToSame} onCheckedChange={(x) => setApplyToSame(x === true)} />
{t.applyToSame}
</label>
) : null}
</div>
<ul
className={cn("divide-y rounded-lg border", !isDraft && "pointer-events-none opacity-90")}
>
{pageRows.map((r) => (
<RowItem key={r.id} row={r} draft={draft} onEdit={edit} />
))}
{!pageRows.length ? (
<li className="p-6 text-center text-sm text-muted-foreground">{t.empty}</li>
) : null}
</ul>
{visible.length > PAGE ? (
<div className="flex items-center justify-end gap-2 text-sm">
<span className="text-muted-foreground tabular-nums">
{t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)}
</span>
<Button
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
{t.prev}
</Button>
<Button
size="sm"
variant="outline"
disabled={(page + 1) * PAGE >= visible.length}
onClick={() => setPage((p) => p + 1)}
>
{t.next}
</Button>
</div>
) : null}
</Step>
{isDraft ? (
<Step title={t.steps.commit}>
<p className="text-sm">
{t.commitSummary(toCreate.length, rows.length - toCreate.length)}
</p>
{missingRoles.map((r) => (
<p key={r} className="text-sm text-expense">
{t.errors.roleMissing(t.roles[r] ?? r)}
</p>
))}
{missingCategory.length ? (
<p className="text-sm text-expense">
{t.errors.category(String(missingCategory.length))}
</p>
) : null}
<p className="text-xs text-muted-foreground">{t.commitHint}</p>
<div className="flex flex-wrap gap-2">
<Button
onClick={commit}
disabled={busy || missingRoles.length > 0 || missingCategory.length > 0}
>
{busy ? <Loader2 className="animate-spin" /> : null}
{busy ? t.committing : t.commit}
</Button>
<Button variant="ghost" onClick={removeDraft} disabled={busy}>
{t.deleteDraft}
</Button>
</div>
</Step>
) : null}
<AlertDialog open={confirmRevert} onOpenChange={setConfirmRevert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.revertTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.revertDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={revert}>
{t.revert}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { FileSpreadsheet, Loader2, Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { tr } from "@/i18n/tr";
import { uploadImportAction } from "../actions";
const t = tr.imports;
/** Kakeibo çalışma kitabı yükleme: dosya seçilince okunur ve taslağa gidilir. */
export function UploadCard() {
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
async function onPick(file: File | undefined) {
if (!file) return;
setBusy(true);
const form = new FormData();
form.set("file", file);
const r = await uploadImportAction(form);
setBusy(false);
if (input.current) input.current.value = "";
if (!r.ok) {
toast.error(r.error);
return;
}
router.push(`/islemler/ice-aktar/${r.data.id}`);
}
return (
<section className="flex flex-col items-start gap-3 rounded-xl border border-dashed bg-card p-5 sm:flex-row sm:items-center">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<FileSpreadsheet className="size-5 text-muted-foreground" aria-hidden />
</span>
<p className="min-w-0 flex-1 text-sm text-muted-foreground">{t.uploadHint}</p>
<input
ref={input}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="sr-only"
id="import-file"
onChange={(e) => onPick(e.target.files?.[0])}
/>
<Button onClick={() => input.current?.click()} disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : <Upload />}
{busy ? t.uploading : t.upload}
</Button>
</section>
);
}
+264
View File
@@ -0,0 +1,264 @@
import * as XLSX from "xlsx";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
commitImport,
createImportAccount,
createImportDraft,
getImportDraft,
revertImport,
updateImportRow,
} from "./service";
/** Yapay Kakeibo çalışma kitabı (gerçek veri değil; yapı gerçek dosyayla aynı). */
const serial = (ymd: string) =>
Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569;
function workbook(): Buffer {
const card = [
[
"İşlem tarihi",
"Ekstre kesim",
"İşyeri",
"Kategori",
"Kakeibo",
"Kart",
"Taksit",
"Tutar (TL)",
],
[
serial("2026-01-05"),
serial("2026-01-28"),
"Test Market",
"Market & gıda",
"Zorunlu",
"Fiziksel kart",
null,
100,
],
[
serial("2026-01-06"),
serial("2026-01-28"),
"Test Market",
"Market & gıda",
"İstek",
"Fiziksel kart",
null,
40,
],
[
serial("2025-12-10"),
serial("2026-01-28"),
"Test Yapı (taksit)",
"Ev / tadilat / yapı market",
"Beklenmeyen",
"Fiziksel kart",
"1/3 (300,00 TL)",
100,
],
[
serial("2025-12-10"),
serial("2026-02-28"),
"Test Yapı (taksit)",
"Ev / tadilat / yapı market",
"Beklenmeyen",
"Fiziksel kart",
"2/3 (300,00 TL)",
100,
],
[
serial("2026-01-07"),
serial("2026-01-28"),
"Test Esnaf",
"Tanımsız esnaf",
null,
"Fiziksel kart",
null,
20,
],
[
serial("2026-01-08"),
serial("2026-01-28"),
"Kampanya iadesi",
"Kampanya iadesi",
"İstek",
"Fiziksel kart",
null,
-10,
],
];
const bank = [
["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"],
[serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null],
[serial("2026-01-02"), "Yol parası", "Yol parası", 500, 1500, null],
[serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -200, 1300, null],
[serial("2026-01-04"), "Kredi kartı borç ödemesi", "Kredi kartı ödemesi", -250, 1050, null],
[serial("2026-01-05"), "Giden havale (kişi)", "Havale/EFT (kişi)", -50, 1000, null],
[serial("2026-01-06"), "KMH faiz tahakkuku", "KMH faiz ve vergi", -5, 995, null],
];
const summary = [
["Kakeibo özeti"],
[
"Ekstre kesim",
"Zorunlu",
"İstek",
"Kültür",
"Beklenmeyen",
"Toplam",
"Ekstredeki harcama",
"Fark",
],
[serial("2026-01-28"), 0, 0, 0, 0, 0, 250, 0],
[serial("2026-02-28"), 0, 0, 0, 0, 0, 100, 0],
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), "Özet");
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(card), "Kredi Kartı");
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(bank), "Maaş Hesabı");
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
}
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let household = "";
let ayse = "";
let zeynep = "";
beforeAll(async () => {
ids = await setupSeededDb();
// Tertemiz bir hane: Annemler'de ayse izleyici; testte Çakır yerine yeni hesaplar açılır.
household = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
});
afterAll(teardownDb);
describe("Kakeibo çalışma kitabı içe aktarma", () => {
let batch = "";
const accounts: Record<string, string> = {};
it("taslak: öneriler, kontroller, ihtiyaç duyulan roller; izleyici aktaramaz", async () => {
expect(
(await createImportDraft(zeynep, household, { name: "x.xlsx", buffer: workbook() })).ok,
).toBe(false);
const r = await createImportDraft(ayse, household, {
name: "kakeibo.xlsx",
buffer: workbook(),
});
expect(r.ok).toBe(true);
batch = r.ok ? r.data.id : "";
const d = (await getImportDraft(ayse, household, batch))!;
expect(d.rows).toHaveLength(11);
expect(d.checks.statements.every((s) => s.ok)).toBe(true);
expect(d.checks.balance).toEqual({ mismatchLine: null, closingMinor: 99_500 });
expect(d.neededRoles).toEqual(["card", "bank", "cash"]);
expect(d.presets.bank).toMatchObject({ openingMinor: 100_000, openingDate: "2026-01-01" });
expect(d.presets.card).toMatchObject({ openingMinor: 25_000 });
expect(d.newCategories).toContain("Yol parası");
// İşlenince oluşacak kategori önizlemede eksik sayılmaz; seçilebilir.
const yol = d.rows.find((r) => r.description === "Yol parası")!;
expect(yol.categoryId).toBe("new:kakeibo.yol");
expect(d.categories.find((c) => c.id === "new:kakeibo.yol")).toMatchObject({ kind: "income" });
expect(
d.rows.filter((r) => (r.kind === "expense" || r.kind === "income") && !r.categoryId),
).toEqual([]);
});
it("önerilen hesaplar oluşturulup rollere bağlanır", async () => {
const d = (await getImportDraft(ayse, household, batch))!;
for (const role of ["bank", "card", "cash"] as const) {
const p = d.presets[role]!;
const r = await createImportAccount(ayse, household, batch, role, {
name: `Test ${p.name}`,
openingMinor: p.openingMinor,
openingDate: p.openingDate,
});
expect(r.ok).toBe(true);
if (r.ok) accounts[role] = r.data.id;
}
expect((await getImportDraft(ayse, household, batch))!.accounts).toMatchObject(accounts);
});
it("satır düzenleme: grupsuz havaleye kategori ve grup; aynı işyerine toplu", async () => {
const d = (await getImportDraft(ayse, household, batch))!;
const havale = d.rows.find((r) => r.description === "Giden havale (kişi)")!;
expect(havale.review).toBe("ungrouped");
const dining = d.categories.find((c) => c.path === "Eğlence › Dışarıda yemek")!;
expect(
(await updateImportRow(ayse, household, batch, { rowId: havale.id, categoryId: dining.id }))
.ok,
).toBe(true);
const market = d.rows.find((r) => r.description === "Test Market")!;
const multi = await updateImportRow(ayse, household, batch, {
rowId: market.id,
kakeiboGroup: "ZORUNLU",
applyToSame: true,
});
expect(multi).toMatchObject({ ok: true, data: { updated: 2 } });
// Geri dön: ikinci market satırı İstek kalsın (kayıt bazında grup testi için).
const second = d.rows.filter((r) => r.description === "Test Market")[1]!;
await updateImportRow(ayse, household, batch, { rowId: second.id, kakeiboGroup: "ISTEK" });
});
it("işle: işlemler, transferler, taksit planı, grup, yeni kategoriler; bakiye dosyayla tutar", async () => {
const r = await commitImport(ayse, household, batch);
expect(r).toMatchObject({ ok: true, data: { transactions: 11, skipped: 0 } });
const txs = await TransactionModel.find({
importBatchId: { $ne: null },
deletedAt: null,
}).lean();
expect(txs).toHaveLength(11);
const cardPay = txs.find((x) => x.payeeName === "Kredi kartı borç ödemesi")!;
expect(cardPay).toMatchObject({ type: "transfer", amountMinor: 25_000 });
expect(String(cardPay.accountId)).toBe(accounts.bank);
expect(String(cardPay.toAccountId)).toBe(accounts.card);
const inst = txs.filter((x) => x.installment);
expect(inst).toHaveLength(2);
expect(String(inst[0]!.installment!.planId)).toBe(String(inst[1]!.installment!.planId));
const markets = txs.filter((x) => x.payeeName === "Test Market");
expect(markets.map((x) => x.kakeiboGroup).sort()).toEqual(["ISTEK", null]);
const refund = txs.find((x) => x.payeeName === "Kampanya iadesi")!;
expect(refund.type).toBe("income");
expect(await CategoryModel.exists({ defaultKey: "kakeibo.yol" })).toBeTruthy();
const list = (await listAccounts(ayse, household))!.accounts;
const bal = (id: string) => list.find((a) => a.id === id)!.balanceMinor;
expect(bal(accounts.bank!)).toBe(99_500); // dosyadaki son bakiye
expect(bal(accounts.cash!)).toBe(20_000);
// Kart: −250 açılış + 250 ödeme − (100 + 40 + 100 + 100 + 20) + 10 iade.
expect(bal(accounts.card!)).toBe(-35_000);
});
it("aynı dosya tekrar yüklenince tüm satırlar daha önce aktarılmış sayılır", async () => {
const again = await createImportDraft(ayse, household, {
name: "kakeibo.xlsx",
buffer: workbook(),
});
const d = (await getImportDraft(ayse, household, again.ok ? again.data.id : ""))!;
expect(d.rows.every((r) => r.duplicate)).toBe(true);
expect(d.neededRoles).toEqual([]);
expect(await commitImport(ayse, household, d.id)).toMatchObject({
ok: true,
data: { transactions: 0, skipped: 11 },
});
});
it("geri alma işlemleri siler; dosya yeniden aktarılabilir", async () => {
expect(await revertImport(ayse, household, batch)).toMatchObject({
ok: true,
data: { transactions: 11 },
});
expect(
await TransactionModel.countDocuments({ importBatchId: { $ne: null }, deletedAt: null }),
).toBe(0);
expect((await revertImport(ayse, household, batch)).ok).toBe(false);
const fresh = await createImportDraft(ayse, household, {
name: "kakeibo.xlsx",
buffer: workbook(),
});
const d = (await getImportDraft(ayse, household, fresh.ok ? fresh.data.id : ""))!;
expect(d.rows.some((r) => r.duplicate)).toBe(false);
});
});
+816
View File
@@ -0,0 +1,816 @@
import "server-only";
import { createHash } from "node:crypto";
import { Types } from "mongoose";
import * as XLSX from "xlsx";
import { createAccount } from "@/features/accounts/service";
import { hasHouseholdRole } from "@/features/households/roles";
import { resolveMembership, type Membership } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { ImportBatchModel } from "@/models/import-batch";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import { istanbulDayStart } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import {
checkStatements,
firstCardPayment,
IMPORT_CATEGORIES,
parseBankSheet,
parseCardSheet,
parseStatementTotals,
type ImportRole,
type ProposedRow,
type RowKind,
type StatementCheck,
} from "@/shared/import/kakeibo-workbook";
import { isKakeiboGroup, resolveKakeibo, type KakeiboGroup } from "@/shared/kakeibo";
import { accountFormSchema } from "@/shared/schemas/account";
import { compareTr, foldTr } from "@/shared/search";
import { addDays } from "@/shared/recurrence";
/**
* Ekstre içe aktarma (Faz 17a): Kakeibo çalışma kitabı. Okuma: member; hesap oluşturma owner
* (hesap servisinin kuralı). Akış: dosya → taslak (öneriler, tekrar işaretleri, kontroller) →
* hesap eşleme → satır düzenleme → tek transaction'da işle → gerekirse toplu geri al.
*
* Kişisel veri: dosyanın başlık alanları (müşteri/kart no) okunmaz; satır açıklamaları yalnızca
* hanenin kendi verisinde saklanır. Tekrar engeli için işlemde anahtarın SHA-256 özeti tutulur.
*/
const t = tr.imports;
// Server Action gövde sınırı (Next varsayılanı 1 MB) ile uyumlu.
export const MAX_IMPORT_BYTES = 1024 * 1024 - 64 * 1024;
const ROLES: ImportRole[] = ["card", "bank", "cash", "extra"];
const ROLE_TYPES: Record<ImportRole, AccountType[]> = {
card: ["credit_card"],
bank: ["bank"],
cash: ["cash"],
extra: ["bank", "cash"],
gold: [],
};
type RowDoc = ProposedRow & {
_id: Types.ObjectId;
categoryId: Types.ObjectId | null;
duplicate: boolean;
edited: boolean;
};
type BatchDoc = {
_id: Types.ObjectId;
householdId: Types.ObjectId;
fileName: string;
fileHash: string;
status: "draft" | "committed" | "reverted";
accounts: Partial<Record<ImportRole, string>>;
rows: RowDoc[];
checks: ImportChecks;
committed: { transactions: number; skipped: number; categoriesCreated: number } | null;
committedAt: Date | null;
revertedAt: Date | null;
createdAt: Date;
};
export type ImportChecks = {
statements: StatementCheck[];
balance: { mismatchLine: number | null; closingMinor: number | null };
opening: {
bank: { date: string; balanceMinor: number } | null;
card: { date: string; amountMinor: number } | null;
};
/** Kart satırlarının en erken tarihi (kart açılış tarihi önerisi). */
firstCardDate: string | null;
firstBankDate: string | null;
};
const hashKey = (key: string) => createHash("sha256").update(key).digest("hex");
// --- Dosya okuma ---
/** Kakeibo çalışma kitabını okur. Sayfalar yoksa açık hata döner. */
export function readWorkbook(
buffer: Buffer,
): ActionResult<{ rows: ProposedRow[]; checks: ImportChecks }> {
let wb: XLSX.WorkBook;
try {
wb = XLSX.read(buffer, { type: "buffer", cellDates: false, dense: true });
} catch {
return fail(t.errors.unreadable);
}
const sheet = (name: string) => {
const ws = wb.Sheets[name];
return ws
? (XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as unknown[][])
: null;
};
const cardRows = sheet("Kredi Kartı");
const bankRows = sheet("Maaş Hesabı");
if (!cardRows && !bankRows) return fail(t.errors.noSheets);
const card = cardRows ? parseCardSheet(cardRows) : [];
const bank = bankRows ? parseBankSheet(bankRows) : null;
const summary = sheet("Özet");
const rows = [...card, ...(bank?.rows ?? [])];
const dates = (s: "card" | "bank") =>
rows
.filter((r) => r.sheet === s)
.map((r) => r.date)
.sort();
return ok({
rows,
checks: {
statements: summary ? checkStatements(card, parseStatementTotals(summary)) : [],
balance: {
mismatchLine: bank?.balanceMismatch?.line ?? null,
closingMinor: bank?.closingBalanceMinor ?? null,
},
opening: { bank: bank?.opening ?? null, card: firstCardPayment(rows) },
firstCardDate: dates("card")[0] ?? null,
firstBankDate: dates("bank")[0] ?? null,
},
});
}
// --- Görünümler ---
export type ImportRowView = {
id: string;
sheet: "card" | "bank";
line: number;
date: string;
statementCut: string | null;
description: string;
amountMinor: number;
kind: RowKind;
categoryId: string | null;
kakeiboGroup: KakeiboGroup | null;
counterRole: ImportRole | null;
installment: { index: number; count: number; totalMinor: number } | null;
source: { category: string; type: string };
review: string | null;
duplicate: boolean;
edited: boolean;
};
export type ImportSummary = {
id: string;
fileName: string;
status: BatchDoc["status"];
createdAt: string;
committedAt: string | null;
revertedAt: string | null;
rowCount: number;
committed: BatchDoc["committed"];
};
export type AccountPreset = {
role: ImportRole;
name: string;
type: AccountType;
openingMinor: number;
openingDate: string;
};
export type ImportDraft = ImportSummary & {
rows: ImportRowView[];
checks: ImportChecks;
accounts: Partial<Record<ImportRole, string>>;
/** Satırların ihtiyaç duyduğu roller. */
neededRoles: ImportRole[];
accountOptions: { id: string; name: string; type: AccountType }[];
presets: Record<string, AccountPreset>;
categories: {
id: string;
path: string;
kind: "expense" | "income";
group: KakeiboGroup | null;
bankCost: boolean;
}[];
/** Henüz hanede olmayan ve işlenince oluşturulacak kategoriler. */
newCategories: string[];
canCreateAccounts: boolean;
};
const summaryOf = (b: BatchDoc): ImportSummary => ({
id: String(b._id),
fileName: b.fileName,
status: b.status,
createdAt: b.createdAt.toISOString(),
committedAt: b.committedAt?.toISOString() ?? null,
revertedAt: b.revertedAt?.toISOString() ?? null,
rowCount: b.rows.length,
committed: b.committed,
});
type CatLean = {
_id: Types.ObjectId;
kind: "expense" | "income";
name: string;
parentId: Types.ObjectId | null;
defaultKey: string | null;
archivedAt: Date | null;
kakeiboGroup?: KakeiboGroup | null;
bankCost?: boolean;
};
async function loadCategories(householdId: Types.ObjectId) {
const cats = await CategoryModel.find({ householdId }).lean<CatLean[]>();
const byId = new Map(cats.map((c) => [String(c._id), c]));
const path = (c: CatLean): string => {
const p = c.parentId ? byId.get(String(c.parentId)) : undefined;
return p ? `${path(p)} › ${c.name}` : c.name;
};
const resolved = resolveKakeibo(
cats.map((c) => ({
id: String(c._id),
parentId: c.parentId ? String(c.parentId) : null,
kakeiboGroup: c.kakeiboGroup ?? null,
bankCost: Boolean(c.bankCost),
})),
);
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
return { cats, byId, byKey, path, resolved };
}
const rowCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
r.categoryId
? String(r.categoryId)
: r.categoryKey
? byKey.get(r.categoryKey)
? String(byKey.get(r.categoryKey)!._id)
: null
: null;
/** İşlenirken oluşturulacak kategori için önizlemedeki sözde kimlik. */
export const NEW_CATEGORY_PREFIX = "new:";
const IMPORT_KEYS = new Set(IMPORT_CATEGORIES.map((x) => x.key));
const draftCategoryId = (r: RowDoc, byKey: Map<string, CatLean>) =>
rowCategoryId(r, byKey) ??
(r.categoryId === null && r.categoryKey && IMPORT_KEYS.has(r.categoryKey)
? NEW_CATEGORY_PREFIX + r.categoryKey
: null);
function neededRoles(rows: readonly RowDoc[]): ImportRole[] {
const need = new Set<ImportRole>();
for (const r of rows) {
if (r.kind === "skip" || r.kind === "deferred" || r.duplicate) continue;
need.add(r.sheet);
if (r.kind === "transfer" && r.counterRole) need.add(r.counterRole);
}
return ROLES.filter((x) => need.has(x));
}
async function loadBatch(m: Membership, batchId: string) {
const id = toObjectId(batchId);
if (!id) return null;
return ImportBatchModel.findOne({ _id: id, householdId: m.householdId }).lean<BatchDoc>();
}
// --- Okuma ---
export async function listImports(
userId: string,
householdId: string,
): Promise<ImportSummary[] | null> {
const m = await resolveMembership(userId, householdId, "member");
if (!m) return null;
const rows = await ImportBatchModel.find({ householdId: m.householdId })
.select({ rows: 0 })
.sort({ createdAt: -1 })
.limit(50)
.lean<(Omit<BatchDoc, "rows"> & { rows?: undefined })[]>();
const counts = await ImportBatchModel.aggregate<{ _id: Types.ObjectId; n: number }>([
{ $match: { householdId: m.householdId } },
{ $project: { n: { $size: "$rows" } } },
]);
const n = new Map(counts.map((c) => [String(c._id), c.n]));
return rows.map((b) => ({
...summaryOf({ ...b, rows: [] } as BatchDoc),
rowCount: n.get(String(b._id)) ?? 0,
}));
}
export async function getImportDraft(
userId: string,
householdId: string,
batchId: string,
): Promise<ImportDraft | null> {
const m = await resolveMembership(userId, householdId, "member");
if (!m) return null;
const b = await loadBatch(m, batchId);
if (!b) return null;
const [cats, accounts] = await Promise.all([
loadCategories(m.householdId),
AccountModel.find({ householdId: m.householdId, archivedAt: null })
.select({ name: 1, type: 1, isPrivate: 1, ownerUserId: 1 })
.lean<
{
_id: Types.ObjectId;
name: string;
type: AccountType;
isPrivate: boolean;
ownerUserId: Types.ObjectId | null;
}[]
>(),
]);
const c = b.checks;
const presets: Record<string, AccountPreset> = {
bank: {
role: "bank",
name: t.presets.bank,
type: "bank",
openingMinor: c.opening.bank?.balanceMinor ?? 0,
openingDate: c.opening.bank
? addDays(c.opening.bank.date, 1)
: (c.firstBankDate ?? "2026-01-01"),
},
card: {
role: "card",
name: t.presets.card,
type: "credit_card",
openingMinor: c.opening.card?.amountMinor ?? 0,
openingDate: c.firstCardDate ?? "2026-01-01",
},
cash: {
role: "cash",
name: t.presets.cash,
type: "cash",
openingMinor: 0,
openingDate: c.firstBankDate ?? "2026-01-01",
},
extra: {
role: "extra",
name: t.presets.extra,
type: "bank",
openingMinor: 0,
openingDate: c.firstBankDate ?? "2026-01-01",
},
};
const visible = accounts.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId);
const missingCats = IMPORT_CATEGORIES.filter((x) => !cats.byKey.has(x.key));
const missing = missingCats.map((x) => x.name);
return {
...summaryOf(b),
rows: b.rows.map((r) => ({
id: String(r._id),
sheet: r.sheet,
line: r.line,
date: r.date,
statementCut: r.statementCut,
description: r.description,
amountMinor: r.amountMinor,
kind: r.kind,
categoryId: draftCategoryId(r, cats.byKey),
kakeiboGroup: isKakeiboGroup(r.kakeiboGroup) ? r.kakeiboGroup : null,
counterRole: r.counterRole,
installment: r.installment
? {
index: r.installment.index,
count: r.installment.count,
totalMinor: r.installment.totalMinor,
}
: null,
source: r.source,
review: r.review,
duplicate: r.duplicate,
edited: r.edited,
})),
checks: b.checks,
accounts: b.accounts ?? {},
neededRoles: neededRoles(b.rows),
accountOptions: visible
.map((a) => ({ id: String(a._id), name: a.name, type: a.type }))
.sort((x, y) => compareTr(x.name, y.name)),
presets,
categories: cats.cats
.filter((x) => !x.archivedAt)
.map((x) => {
const k = cats.resolved.get(String(x._id));
return {
id: String(x._id),
path: cats.path(x),
kind: x.kind,
group: k?.group ?? null,
bankCost: k?.bankCost ?? false,
};
})
.concat(
missingCats.map((x) => ({
id: NEW_CATEGORY_PREFIX + x.key,
path: t.newCategoryName(x.name),
kind: x.kind,
group: x.kind === "expense" ? x.kakeiboGroup : null,
bankCost: false,
})),
)
.sort((x, y) => compareTr(x.path, y.path)),
newCategories: missing,
canCreateAccounts: hasHouseholdRole(m.role, "owner"),
};
}
// --- Yazma ---
export async function createImportDraft(
actorId: string,
householdId: string,
file: { name: string; buffer: Buffer },
): Promise<ActionResult<{ id: string }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
if (file.buffer.byteLength > MAX_IMPORT_BYTES) return fail(t.errors.tooLarge);
const parsed = readWorkbook(file.buffer);
if (!parsed.ok) return parsed;
const { rows, checks } = parsed.data;
if (!rows.length) return fail(t.errors.empty);
// Hanede zaten içe aktarılmış satırlar (aynı anahtar) atlanmak üzere işaretlenir.
const hashes = rows.map((r) => hashKey(r.key));
const existing = new Set(
(
await TransactionModel.find({ householdId: m.householdId, importKey: { $in: hashes } })
.select({ importKey: 1 })
.lean<{ importKey: string }[]>()
).map((x) => x.importKey),
);
const actor = new Types.ObjectId(actorId);
const doc = await ImportBatchModel.create({
householdId: m.householdId,
source: "kakeibo-workbook",
fileName: file.name.slice(0, 200),
fileHash: createHash("sha256").update(file.buffer).digest("hex"),
rows: rows.map((r, i) => ({ ...r, duplicate: existing.has(hashes[i]!) })),
checks,
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
async function draftFor(actorId: string, householdId: string, batchId: string) {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return { error: fail(tr.common.unauthorized) } as const;
const b = await loadBatch(m, batchId);
if (!b) return { error: fail(tr.common.notFound) } as const;
if (b.status !== "draft") return { error: fail(t.errors.notDraft) } as const;
return { error: null, m, b, actor: new Types.ObjectId(actorId) } as const;
}
/** Bir rolü mevcut bir hesaba bağlar. */
export async function setImportAccount(
actorId: string,
householdId: string,
batchId: string,
role: ImportRole,
accountId: string,
): Promise<ActionResult> {
const r = await draftFor(actorId, householdId, batchId);
if (r.error) return r.error;
const id = toObjectId(accountId);
const acc = id
? await AccountModel.findOne({ _id: id, householdId: r.m.householdId, archivedAt: null })
.select({ type: 1 })
.lean<{ type: AccountType }>()
: null;
if (!acc || !ROLE_TYPES[role].includes(acc.type)) return fail(t.errors.accountType);
await ImportBatchModel.updateOne(
{ _id: r.b._id },
{ $set: { [`accounts.${role}`]: accountId, updatedBy: r.actor } },
);
return ok(undefined);
}
/** Önerilen değerlerle hesap oluşturup role bağlar (hesap oluşturma: owner). */
export async function createImportAccount(
actorId: string,
householdId: string,
batchId: string,
role: ImportRole,
input: { name: string; openingMinor: number; openingDate: string },
): Promise<ActionResult<{ id: string }>> {
const draft = await getImportDraft(actorId, householdId, batchId);
if (!draft || draft.status !== "draft") return fail(tr.common.notFound);
const preset = draft.presets[role];
if (!preset) return fail(tr.common.notFound);
const isCard = preset.type === "credit_card";
const form = accountFormSchema.safeParse({
name: input.name,
type: preset.type,
institution: "",
mealCardProvider: null,
// Kartta açılış borcu pozitif girilir; hesap servisi negatife çevirir. Bankada eksi bakiye
// (KMH) eksi işaretle girilir.
openingBalance: formatMoneyInput(isCard ? Math.abs(input.openingMinor) : input.openingMinor),
openingDate: input.openingDate,
color: isCard ? "purple" : preset.type === "cash" ? "green" : "blue",
icon: isCard ? "credit-card" : preset.type === "cash" ? "wallet" : "landmark",
ownerUserId: "",
isPrivate: false,
overdraftEnabled: role === "bank",
overdraftLimit: "0",
overdraftMonthlyRate: "",
overdraftStatementDay: "",
cardLimit: "0",
cardStatementDay: "28",
cardDueKind: "daysAfter",
cardDueValue: "12",
cardPaymentAccountId: "",
cardMinimumRate: "",
});
if (!form.success) return fail(form.error.issues[0]?.message ?? t.errors.accountType);
const created = await createAccount(actorId, householdId, form.data);
if (!created.ok) return created;
const set = await setImportAccount(actorId, householdId, batchId, role, created.data.id);
if (!set.ok) return set;
return ok({ id: created.data.id });
}
export type ImportRowEdit = {
rowId: string;
kind?: "expense" | "income" | "skip";
categoryId?: string | null;
kakeiboGroup?: KakeiboGroup | null;
/** Aynı açıklamalı (işyeri) tüm satırlara uygula. */
applyToSame?: boolean;
};
export async function updateImportRow(
actorId: string,
householdId: string,
batchId: string,
edit: ImportRowEdit,
): Promise<ActionResult<{ updated: number }>> {
const r = await draftFor(actorId, householdId, batchId);
if (r.error) return r.error;
const row = r.b.rows.find((x) => String(x._id) === edit.rowId);
if (!row) return fail(tr.common.notFound);
const newKey = edit.categoryId?.startsWith(NEW_CATEGORY_PREFIX)
? edit.categoryId.slice(NEW_CATEGORY_PREFIX.length)
: null;
if (newKey !== null && !IMPORT_KEYS.has(newKey))
return fail(tr.transactions.validation.categoryInvalid);
if (edit.categoryId && newKey === null) {
const ok = await CategoryModel.exists({
_id: new Types.ObjectId(edit.categoryId),
householdId: r.m.householdId,
archivedAt: null,
});
if (!ok) return fail(tr.transactions.validation.categoryInvalid);
}
const targets = edit.applyToSame
? r.b.rows.filter(
(x) =>
x.sheet === row.sheet &&
foldTr(x.description) === foldTr(row.description) &&
x.kind === row.kind,
)
: [row];
const ops = targets.map((x) => {
const s: Record<string, unknown> = { "rows.$[r].edited": true };
if (edit.kind) s["rows.$[r].kind"] = edit.kind;
if (newKey !== null) {
s["rows.$[r].categoryId"] = null;
s["rows.$[r].categoryKey"] = newKey;
} else if (edit.categoryId !== undefined)
s["rows.$[r].categoryId"] = edit.categoryId ? new Types.ObjectId(edit.categoryId) : null;
if (edit.kakeiboGroup !== undefined) s["rows.$[r].kakeiboGroup"] = edit.kakeiboGroup;
return {
updateOne: {
filter: { _id: r.b._id },
update: { $set: { ...s, updatedBy: r.actor } },
arrayFilters: [{ "r._id": x._id }],
},
};
});
if (ops.length) await ImportBatchModel.bulkWrite(ops);
return ok({ updated: ops.length });
}
/** Partideki satırları tek transaction'da işler. */
export async function commitImport(
actorId: string,
householdId: string,
batchId: string,
): Promise<ActionResult<{ transactions: number; skipped: number }>> {
const r = await draftFor(actorId, householdId, batchId);
if (r.error) return r.error;
const { m, b, actor } = r;
const need = neededRoles(b.rows);
const accountIds: Partial<Record<ImportRole, Types.ObjectId>> = {};
for (const role of need) {
const id = b.accounts?.[role] ? toObjectId(b.accounts[role]!) : null;
const acc = id
? await AccountModel.findOne({ _id: id, householdId: m.householdId, archivedAt: null })
.select({ type: 1 })
.lean<{ type: AccountType }>()
: null;
if (!acc || !ROLE_TYPES[role].includes(acc.type))
return fail(t.errors.roleMissing(t.roles[role] ?? role));
accountIds[role] = id!;
}
const mongoose = await connectMongoose();
let inserted = 0;
let skipped = 0;
let categoriesCreated = 0;
try {
await mongoose.connection.transaction(async (session) => {
inserted = 0;
skipped = 0;
categoriesCreated = 0;
// Eksik içe aktarma kategorileri (anahtar: kakeibo.*).
const existing = await CategoryModel.find({
householdId: m.householdId,
defaultKey: { $in: IMPORT_CATEGORIES.map((x) => x.key) },
})
.select({ defaultKey: 1 })
.session(session)
.lean<{ defaultKey: string }[]>();
const have = new Set(existing.map((x) => x.defaultKey));
const missing = IMPORT_CATEGORIES.filter((x) => !have.has(x.key));
if (missing.length) {
await CategoryModel.insertMany(
missing.map((x, i) => ({
householdId: m.householdId,
kind: x.kind,
name: x.name,
nameKey: foldTr(x.name),
parentId: null,
ancestors: [],
icon: x.icon,
color: x.color,
order: 100 + i,
defaultKey: x.key,
kakeiboGroup: x.kind === "expense" ? x.kakeiboGroup : null,
bankCost: false,
createdBy: actor,
updatedBy: actor,
})),
{ session },
);
categoriesCreated = missing.length;
}
const cats = await CategoryModel.find({ householdId: m.householdId })
.session(session)
.lean<CatLean[]>();
const byKey = new Map(cats.filter((c) => c.defaultKey).map((c) => [c.defaultKey!, c]));
const byId = new Map(cats.map((c) => [String(c._id), c]));
const resolved = resolveKakeibo(
cats.map((c) => ({
id: String(c._id),
parentId: c.parentId ? String(c.parentId) : null,
kakeiboGroup: c.kakeiboGroup ?? null,
bankCost: Boolean(c.bankCost),
})),
);
const plans = new Map<string, Types.ObjectId>();
const docs: Record<string, unknown>[] = [];
for (const row of b.rows) {
if (row.duplicate || row.kind === "skip" || row.kind === "deferred") {
skipped++;
continue;
}
const own = accountIds[row.sheet]!;
const base = {
householdId: m.householdId,
status: "posted",
date: istanbulDayStart(row.date)!,
amountMinor: Math.abs(row.amountMinor),
payeeName: row.description.slice(0, 80),
description: "",
searchText: foldTr(row.description),
tags: [t.tag],
importBatchId: b._id,
importKey: hashKey(row.key),
createdBy: actor,
updatedBy: actor,
};
if (row.kind === "transfer") {
const other = accountIds[row.counterRole as ImportRole]!;
const out = row.amountMinor < 0;
docs.push({
...base,
type: "transfer",
accountId: out ? own : other,
toAccountId: out ? other : own,
categoryId: null,
splits: [],
effectiveCategoryIds: [],
});
continue;
}
const catId = rowCategoryId(row, byKey);
const cat = catId ? byId.get(catId) : undefined;
const kind = row.kind as "expense" | "income";
if (!cat || cat.kind !== kind || cat.archivedAt)
throw new ImportError(fail(t.errors.category(row.description || String(row.line))));
const group = isKakeiboGroup(row.kakeiboGroup) ? row.kakeiboGroup : null;
const categoryGroup = resolved.get(catId!)?.group ?? null;
let installment = null;
if (row.installment && kind === "expense") {
const planId = plans.get(row.installment.key) ?? new Types.ObjectId();
plans.set(row.installment.key, planId);
installment = { planId, index: row.installment.index, count: row.installment.count };
}
docs.push({
...base,
type: kind,
accountId: own,
toAccountId: null,
categoryId: cat._id,
splits: [],
effectiveCategoryIds: [cat._id],
kakeiboGroup: kind === "expense" && group && group !== categoryGroup ? group : null,
installment,
});
}
if (docs.length) await TransactionModel.insertMany(docs, { session });
inserted = docs.length;
await ImportBatchModel.updateOne(
{ _id: b._id, status: "draft" },
{
$set: {
status: "committed",
committedAt: new Date(),
committed: { transactions: inserted, skipped, categoriesCreated },
updatedBy: actor,
},
},
{ session },
);
});
} catch (error) {
if (error instanceof ImportError) return error.result;
if ((error as { code?: number }).code === 11000) return fail(t.errors.duplicate);
throw error;
}
await writeAudit({
action: "import.commit",
actorId: actor,
householdId: m.householdId,
targetType: "importBatch",
targetId: String(b._id),
diff: { transactions: inserted, skipped, categoriesCreated, fileName: b.fileName },
});
return ok({ transactions: inserted, skipped });
}
class ImportError extends Error {
constructor(readonly result: ActionResult<never>) {
super(result.ok ? "" : result.error);
}
}
/** İşlenmiş partinin işlemlerini siler (soft delete; anahtar boşaltılır, yeniden içe aktarılabilir). */
export async function revertImport(
actorId: string,
householdId: string,
batchId: string,
): Promise<ActionResult<{ transactions: number }>> {
const m = await resolveMembership(actorId, householdId, "member");
if (!m) return fail(tr.common.unauthorized);
const b = await loadBatch(m, batchId);
if (!b) return fail(tr.common.notFound);
if (b.status !== "committed") return fail(t.errors.notCommitted);
const actor = new Types.ObjectId(actorId);
const mongoose = await connectMongoose();
let n = 0;
await mongoose.connection.transaction(async (session) => {
const res = await TransactionModel.updateMany(
{ householdId: m.householdId, importBatchId: b._id, deletedAt: null },
{ $set: { deletedAt: new Date(), importKey: null, updatedBy: actor } },
{ session },
);
n = res.modifiedCount;
await ImportBatchModel.updateOne(
{ _id: b._id },
{ $set: { status: "reverted", revertedAt: new Date(), updatedBy: actor } },
{ session },
);
});
await writeAudit({
action: "import.revert",
actorId: actor,
householdId: m.householdId,
targetType: "importBatch",
targetId: String(b._id),
diff: { transactions: n, fileName: b.fileName },
});
return ok({ transactions: n });
}
/** Taslağı siler (yalnızca işlenmemiş). */
export async function deleteImportDraft(
actorId: string,
householdId: string,
batchId: string,
): Promise<ActionResult> {
const r = await draftFor(actorId, householdId, batchId);
if (r.error) return r.error;
await ImportBatchModel.deleteOne({ _id: r.b._id, status: "draft" });
return ok(undefined);
}
@@ -1,6 +1,15 @@
"use client";
import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Download,
Filter,
Paperclip,
Plus,
Upload,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
@@ -279,6 +288,14 @@ export function TransactionsView({
<span className="hidden sm:inline">{t.export}</span>
</a>
</Button>
{canWrite ? (
<Button variant="ghost" asChild>
<Link href="/islemler/ice-aktar">
<Upload />
<span className="hidden sm:inline">{t.import}</span>
</Link>
</Button>
) : null}
{canWrite ? (
<Button onClick={() => openQuickAdd()}>
<Plus />
+140
View File
@@ -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",
+2
View File
@@ -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];
+64
View File
@@ -0,0 +1,64 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Ekstre içe aktarma partisi (Faz 17). Taslak (önizleme, düzenleme) → işlendi → geri alındı.
* Satırlar önerilen sınıflandırmayla birlikte saklanır; kullanıcı düzenlemeleri satırda tutulur.
* İşlenince oluşan işlemler `importBatchId` ile partiye bağlıdır (toplu geri alma).
*/
const importRowSchema = new Schema(
{
sheet: { type: String, enum: ["card", "bank"], required: true },
line: { type: Number, required: true },
date: { type: String, required: true },
statementCut: { type: String, default: null },
description: { type: String, default: "" },
amountMinor: { type: Number, required: true },
kind: {
type: String,
enum: ["expense", "income", "transfer", "skip", "deferred"],
required: true,
},
categoryKey: { type: String, default: null },
/** Kullanıcının seçtiği kategori (varsa categoryKey'in yerine). */
categoryId: { type: Schema.Types.ObjectId, default: null },
kakeiboGroup: { type: String, default: null },
counterRole: { type: String, default: null },
installment: { type: Schema.Types.Mixed, default: null },
source: { type: Schema.Types.Mixed, default: {} },
review: { type: String, default: null },
key: { type: String, required: true },
/** Bu anahtar hanede zaten içe aktarılmış (varsayılan: atlanır). */
duplicate: { type: Boolean, default: false },
edited: { type: Boolean, default: false },
},
{ _id: true },
);
const importBatchSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
source: { type: String, enum: ["kakeibo-workbook"], required: true },
fileName: { type: String, required: true, maxlength: 200 },
fileHash: { type: String, required: true },
status: { type: String, enum: ["draft", "committed", "reverted"], default: "draft" },
/** Rol → hesap (card, bank, cash, extra). */
accounts: { type: Schema.Types.Mixed, default: {} },
rows: { type: [importRowSchema], default: [] },
/** Dosyadan çıkan kontroller (ekstre toplamları, bakiye zinciri, açılış). */
checks: { type: Schema.Types.Mixed, default: {} },
committed: { type: Schema.Types.Mixed, default: null },
committedAt: { type: Date, default: null },
revertedAt: { type: Date, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "importBatches" },
);
importBatchSchema.index({ householdId: 1, createdAt: -1 });
export type ImportBatch = InferSchemaType<typeof importBatchSchema>;
export const ImportBatchModel: Model<ImportBatch> =
(models.ImportBatch as Model<ImportBatch> | undefined) ??
model<ImportBatch>("ImportBatch", importBatchSchema);
+12
View File
@@ -81,6 +81,9 @@ const transactionSchema = new Schema(
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
/** 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 });
+277
View File
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
bankExpenseCategory,
checkStatements,
excelDate,
firstCardPayment,
parseBankSheet,
parseCardSheet,
parseInstallment,
parseStatementTotals,
refineCategory,
} from "./kakeibo-workbook";
/** "YYYY-MM-DD" → Excel seri günü (test verisi için). */
const serial = (ymd: string) =>
Date.UTC(+ymd.slice(0, 4), +ymd.slice(5, 7) - 1, +ymd.slice(8, 10)) / 86_400_000 + 25569;
const CARD_HEAD = [
"İşlem tarihi",
"Ekstre kesim",
"İşyeri",
"Kategori",
"Kakeibo",
"Kart",
"Taksit",
"Tutar (TL)",
];
const BANK_HEAD = ["Tarih", "Açıklama", "Tür", "Tutar (TL)", "Bakiye (TL)", "Ay"];
describe("hücre yardımcıları", () => {
it("Excel seri günü saat dilimi kaymadan güne çözülür", () => {
expect(excelDate(serial("2026-01-28"))).toBe("2026-01-28");
expect(excelDate(serial("2025-12-31"))).toBe("2025-12-31");
expect(excelDate("x")).toBeNull();
});
it("taksit metni", () => {
expect(parseInstallment("2/7 (3.465,95 TL)")).toEqual({
index: 2,
count: 7,
totalMinor: 346_595,
});
expect(parseInstallment("1/9 (28.200,00 TL)")).toEqual({
index: 1,
count: 9,
totalMinor: 2_820_000,
});
expect(parseInstallment("")).toBeNull();
});
it("işyerine göre alt kategori ve banka açıklaması", () => {
expect(refineCategory("faturalar", "CK Boğaziçi Elektrik")).toBe("faturalar.elektrik");
expect(refineCategory("faturalar", "İGDAŞ")).toBe("faturalar.dogalgaz");
expect(refineCategory("faturalar", "Vodafone")).toBe("faturalar.telefon");
expect(refineCategory("ulasim", "Petrol Ofisi")).toBe("ulasim.yakit");
expect(refineCategory("ulasim", "İstanbulkart")).toBe("ulasim.toplu-tasima");
expect(refineCategory("saglik", "Yaşam Eczanesi")).toBe("saglik.ilac");
expect(refineCategory("market.gida", "BİM")).toBe("market.gida");
expect(bankExpenseCategory("Banka kartı harcaması", "Banka kartı – börekçi")).toBe(
"eglence.kafe",
);
expect(bankExpenseCategory("KMH faiz ve vergi", "KMH KKDF / BSMV")).toBe("banka.vergi");
expect(bankExpenseCategory("KMH faiz ve vergi", "KMH faiz tahakkuku")).toBe("banka.faiz");
expect(bankExpenseCategory("Havale/EFT (kişi)", "Giden havale – yemek")).toBe(
"eglence.disarida-yemek",
);
});
});
describe("parseCardSheet", () => {
const rows = [
CARD_HEAD,
[
serial("2025-12-28"),
serial("2026-01-28"),
"Elma Market",
"Market & gıda",
"Zorunlu",
"Fiziksel kart",
null,
98,
],
[
serial("2025-12-29"),
serial("2026-01-28"),
"Elma Market",
"Market & gıda",
"Zorunlu",
"Fiziksel kart",
null,
98,
],
[
serial("2025-12-29"),
serial("2026-01-28"),
"Elma Market",
"Market & gıda",
"Zorunlu",
"Fiziksel kart",
null,
98,
],
[
serial("2025-12-06"),
serial("2026-01-28"),
"Koçtaş (taksit)",
"Ev / tadilat / yapı market",
"Beklenmeyen",
"Fiziksel kart",
"2/7 (3.465,95 TL)",
495.14,
],
[
serial("2026-01-05"),
serial("2026-01-28"),
"KoçAilem kampanya iadesi",
"Kampanya iadesi",
"İstek",
"Fiziksel kart",
null,
-50,
],
[
serial("2026-01-06"),
serial("2026-01-28"),
"Test Esnaf",
"Tanımsız esnaf",
null,
"Fiziksel kart",
null,
120,
],
[
serial("2026-01-07"),
serial("2026-01-28"),
"Simitçi",
"Börek, simit, pastane, büfe",
"İstek",
"Dijital kart",
null,
35.5,
],
[null, null, null, null, null, null, null, null],
];
const out = parseCardSheet(rows);
it("harcama çıkış (−), iade giriş (+) ve gelir 'İade'; boş satır atlanır", () => {
expect(out).toHaveLength(7);
expect(out[0]).toMatchObject({
kind: "expense",
amountMinor: -9_800,
categoryKey: "market.gida",
kakeiboGroup: "ZORUNLU",
});
expect(out[4]).toMatchObject({
kind: "income",
amountMinor: 5_000,
categoryKey: "iade",
kakeiboGroup: null,
});
});
it("aynı gün aynı tutar ayrı anahtar alır; taksit kesim tarihinde, plan anahtarıyla", () => {
expect(out[1]!.key).not.toBe(out[2]!.key);
expect(out[3]).toMatchObject({
date: "2026-01-28",
categoryKey: "ev",
kakeiboGroup: "BEKLENMEYEN",
installment: { index: 2, count: 7, totalMinor: 346_595 },
});
});
it("tanımsız esnaf grupsuz ve incelemeye düşer", () => {
expect(out[5]).toMatchObject({
categoryKey: "diger-gider",
kakeiboGroup: null,
review: "ungrouped",
});
expect(out[6]).toMatchObject({ categoryKey: "eglence.kafe", kakeiboGroup: "ISTEK" });
});
it("ekstre kontrolü: satır toplamı (harcama − iade) = ekstredeki harcama", () => {
const total = 98 * 3 + 495.14 - 50 + 120 + 35.5;
const ok = checkStatements(out, new Map([["2026-01-28", Math.round(total * 100)]]));
expect(ok).toEqual([
{
cut: "2026-01-28",
rowsMinor: Math.round(total * 100),
statementMinor: Math.round(total * 100),
ok: true,
},
]);
expect(checkStatements(out, new Map([["2026-01-28", 1]]))[0]!.ok).toBe(false);
});
});
describe("parseBankSheet", () => {
const rows = [
BANK_HEAD,
[serial("2025-12-31"), "Devreden bakiye", "Açılış", null, 1000, null],
[serial("2026-01-02"), "Yol parası ödemesi", "Yol parası", 200, 1200, null],
[serial("2026-01-03"), "ATM nakit çekim", "Nakit çekim (ATM)", -500, 700, null],
[
serial("2026-01-04"),
"Kredi kartı borç ödemesi",
"Kredi kartı ödemesi",
-330.79,
369.21,
null,
],
[serial("2026-01-05"), "Banka kartı – börekçi", "Banka kartı harcaması", -45, 324.21, null],
[serial("2026-01-06"), "KMH KKDF / BSMV", "KMH faiz ve vergi", -10, 314.21, null],
[serial("2026-01-07"), "Altın alımı (Birikim hesabı)", "Altın", -100, 214.21, null],
[serial("2026-01-08"), "Maaş promosyonu", "Promosyon", 250, 464.21, null],
[serial("2026-01-08"), "Giden FAST (promosyon)", "Promosyon", -250, 214.21, null],
[serial("2026-01-09"), "Bilinmeyen", "Yeni tür", -14.21, 200, null],
];
const b = parseBankSheet(rows);
it("açılış, kapanış ve bakiye zinciri", () => {
expect(b.opening).toEqual({ date: "2025-12-31", balanceMinor: 100_000 });
expect(b.closingBalanceMinor).toBe(20_000);
expect(b.balanceMismatch).toBeNull();
});
it("türler: gelir, transfer (nakit/kart), gider, altın bekler, promosyon çifti atlanır", () => {
const by = (desc: string) => b.rows.find((r) => r.description === desc)!;
expect(by("Yol parası ödemesi")).toMatchObject({
kind: "income",
categoryKey: "kakeibo.yol",
amountMinor: 20_000,
});
expect(by("ATM nakit çekim")).toMatchObject({ kind: "transfer", counterRole: "cash" });
expect(by("Kredi kartı borç ödemesi")).toMatchObject({ kind: "transfer", counterRole: "card" });
expect(by("Banka kartı – börekçi")).toMatchObject({
kind: "expense",
categoryKey: "eglence.kafe",
});
expect(by("KMH KKDF / BSMV")).toMatchObject({ kind: "expense", categoryKey: "banka.vergi" });
expect(by("Altın alımı (Birikim hesabı)")).toMatchObject({
kind: "deferred",
counterRole: "gold",
});
expect(
b.rows.filter((r) => r.source.type === "Promosyon").every((r) => r.kind === "skip"),
).toBe(true);
expect(by("Bilinmeyen")).toMatchObject({ kind: "expense", review: "unknownType" });
expect(firstCardPayment(b.rows)).toEqual({ date: "2026-01-04", amountMinor: 33_079 });
});
it("bakiye zinciri kırılırsa ilk uyuşmayan satır bildirilir", () => {
const broken = rows.map((r, i) => (i === 3 ? [...r.slice(0, 4), 999, null] : r));
expect(parseBankSheet(broken).balanceMismatch).toMatchObject({
line: 3,
expectedMinor: 70_000,
actualMinor: 99_900,
});
});
});
describe("parseStatementTotals", () => {
it("Özet 1. bölümünden kesim → ekstredeki harcama", () => {
const rows = [
["Kakeibo özeti"],
[],
["1. Kart harcaması"],
[
"Ekstre kesim",
"Zorunlu",
"İstek",
"Kültür",
"Beklenmeyen",
"Toplam",
"Ekstredeki harcama",
"Fark",
],
[serial("2026-01-28"), 1, 2, 3, 4, 10, 476.54, 0],
[serial("2026-03-01"), 1, 2, 3, 4, 10, 374.7, 0],
["Toplam"],
];
expect([...parseStatementTotals(rows)]).toEqual([
["2026-01-28", 47_654],
["2026-03-01", 37_470],
]);
});
});
+473
View File
@@ -0,0 +1,473 @@
import { isKakeiboGroup, type KakeiboGroup } from "../kakeibo";
/**
* Kakeibo çalışma kitabı içe aktarma motoru (Faz 17a). Saf fonksiyonlar; Excel okuma (SheetJS)
* dışarıda yapılır, burada ham satır dizileri ([[hücre, …], …]) işlenir.
*
* Sayfalar:
* "Kredi Kartı": İşlem tarihi | Ekstre kesim | İşyeri | Kategori | Kakeibo | Kart | Taksit | Tutar (TL)
* "Maaş Hesabı": Tarih | Açıklama | Tür | Tutar (TL) | Bakiye (TL) | Ay
* "Özet": 1. bölümde ekstre kesimi başına "Ekstredeki harcama" (doğrulama için).
*
* Tarihler Excel seri numarası (gün) olarak gelir ve gün olarak çözülür (saat dilimi kayması
* yok). Tutarlar kuruşa yuvarlanır. Kart satırında pozitif tutar harcama, negatif tutar iadedir.
*/
export type ImportRole = "card" | "bank" | "cash" | "extra" | "gold";
export type RowKind = "expense" | "income" | "transfer" | "skip" | "deferred";
/** Yeni oluşturulacak (varsayılan sette olmayan) kategoriler; anahtar `kakeibo.*`. */
export const IMPORT_CATEGORIES: {
key: string;
kind: "expense" | "income";
name: string;
icon: string;
color: string;
kakeiboGroup: KakeiboGroup | null;
}[] = [
{
key: "kakeibo.sigorta",
kind: "expense",
name: "Sigorta",
icon: "heart-pulse",
color: "red",
kakeiboGroup: "ZORUNLU",
},
{
key: "kakeibo.online",
kind: "expense",
name: "Online alışveriş",
icon: "shopping-basket",
color: "purple",
kakeiboGroup: "ISTEK",
},
{
key: "kakeibo.elektronik",
kind: "expense",
name: "Elektronik ve hobi",
icon: "gamepad",
color: "indigo",
kakeiboGroup: "ISTEK",
},
{
key: "kakeibo.kultur",
kind: "expense",
name: "Kitap ve kültür",
icon: "book",
color: "teal",
kakeiboGroup: "KULTUR",
},
{
key: "kakeibo.prim",
kind: "income",
name: "Prim (maaş farkı)",
icon: "trending-up",
color: "green",
kakeiboGroup: null,
},
{
key: "kakeibo.yol",
kind: "income",
name: "Yol parası",
icon: "bus",
color: "teal",
kakeiboGroup: null,
},
{
key: "kakeibo.avans",
kind: "income",
name: "Avans",
icon: "briefcase",
color: "slate",
kakeiboGroup: null,
},
{
key: "kakeibo.es-katkisi",
kind: "income",
name: "Eş katkısı",
icon: "coins",
color: "pink",
kakeiboGroup: null,
},
{
key: "kakeibo.aile-destegi",
kind: "income",
name: "Aile desteği",
icon: "gift",
color: "amber",
kakeiboGroup: null,
},
];
export type ProposedRow = {
/** Sayfa ve sıra (1'den, başlık hariç). */
sheet: "card" | "bank";
line: number;
date: string;
/** Kart satırında ekstre kesimi. */
statementCut: string | null;
description: string;
/** İşaretli tutar: hesap açısından giriş (+) / çıkış (−), kuruş. */
amountMinor: number;
kind: RowKind;
/** Kategori varsayılan anahtarı (gelir/giderde). */
categoryKey: string | null;
/** Kaynağın Kakeibo grubu (yalnızca giderde; kategorinin grubundan farklıysa kayıtta değiştirilir). */
kakeiboGroup: KakeiboGroup | null;
/** Transferde karşı hesabın rolü (bank satırında). */
counterRole: ImportRole | null;
installment: { key: string; index: number; count: number; totalMinor: number } | null;
/** Kaynaktaki ham sınıflandırma (önizlemede gösterilir). */
source: { category: string; type: string };
/** Kullanıcının bakması gereken satır (grupsuz, işaret uyuşmazlığı, bilinmeyen tür). */
review: string | null;
/** Tekrar anahtarı (aynı gün aynı tutar ve açıklamada sıra numarası dahil). */
key: string;
};
// --- Hücre yardımcıları ---
/** Excel seri günü → "YYYY-MM-DD" (1900 tarih sistemi, 1900-02-29 hatası dahil). */
export function excelDate(v: unknown): string | null {
if (typeof v === "number" && Number.isFinite(v)) {
const ms = Math.round((v - 25569) * 86_400_000);
const d = new Date(ms);
return d.toISOString().slice(0, 10);
}
if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10);
return null;
}
export const toMinor = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? Math.round(v * 100) : null;
const text = (v: unknown) => (v === null || v === undefined ? "" : String(v).trim());
const fold = (s: string) =>
s
.toLocaleLowerCase("tr-TR")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/ı/g, "i")
.replace(/\s+/g, " ")
.trim();
// --- Kategori eşleştirme ---
const CARD_CATEGORY: Record<string, string> = {
"market & gida": "market.gida",
"borek, simit, pastane, bufe": "eglence.kafe",
"disarida yemek & paket servis": "eglence.disarida-yemek",
"dijital abonelik & yazilim": "abonelikler",
"elektronik, beyaz esya & hobi": "kakeibo.elektronik",
"ev / tadilat / yapi market": "ev",
"faturalar (elektrik, su, gaz, internet, gsm)": "faturalar",
"giyim & ayakkabi": "giyim",
"hayat/kredi sigortasi": "kakeibo.sigorta",
"kitap & kultur & eglence": "kakeibo.kultur",
"kozmetik & kisisel bakim": "market.kisisel-bakim",
"online alisveris": "kakeibo.online",
"saglik (eczane, hastane, doktor)": "saglik",
"tanimsiz esnaf": "diger-gider",
"tatil & seyahat": "eglence.tatil",
"ulasim & yakit": "ulasim",
"cocuk, okul & egitim": "cocuk",
"belediye & resmi": "vergi",
};
/** Üst kategoriyi işyerine göre alt kategoriye inceltir (fatura türü, yakıt/toplu taşıma, eczane). */
export function refineCategory(key: string, merchant: string): string {
const m = fold(merchant);
if (key === "faturalar") {
if (/ck bogazici|enerjisa|bedas|ayedas|elektrik/.test(m)) return "faturalar.elektrik";
if (/igdas|dogalgaz|dogal gaz/.test(m)) return "faturalar.dogalgaz";
if (/iski|\bsu\b/.test(m)) return "faturalar.su";
if (/turknet|superonline|ttnet|turk telekom|internet/.test(m)) return "faturalar.internet";
if (/vodafone|turkcell|gsm/.test(m)) return "faturalar.telefon";
}
if (key === "ulasim") {
if (/petrol|opet|shell|\bbp\b|total|akaryakit|yakit/.test(m)) return "ulasim.yakit";
if (/istanbulkart|taksi|marti|bitaksi|metro|otobus/.test(m)) return "ulasim.toplu-tasima";
}
if (key === "saglik" && /eczane/.test(m)) return "saglik.ilac";
return key;
}
/** Taksit metni: "2/7 (3.465,95 TL)" → sıra, adet, toplam. */
export function parseInstallment(
v: unknown,
): { index: number; count: number; totalMinor: number } | null {
const m = /^(\d+)\s*\/\s*(\d+)\s*\(([\d.]+),(\d{2})\s*TL\)/.exec(text(v));
if (!m) return null;
return {
index: Number(m[1]),
count: Number(m[2]),
totalMinor: Number(m[3]!.replace(/\./g, "")) * 100 + Number(m[4]),
};
}
const groupOf = (v: unknown): KakeiboGroup | null => {
const g = fold(text(v));
const map: Record<string, KakeiboGroup> = {
zorunlu: "ZORUNLU",
istek: "ISTEK",
kultur: "KULTUR",
beklenmeyen: "BEKLENMEYEN",
};
const x = map[g];
return x && isKakeiboGroup(x) ? x : null;
};
/** Aynı gün + tutar + açıklama tekrarlarını ayırmak için sıra numaralı anahtar. */
function keyer() {
const seen = new Map<string, number>();
return (base: string) => {
const n = (seen.get(base) ?? 0) + 1;
seen.set(base, n);
return `${base}#${n}`;
};
}
// --- Kart sayfası ---
export function parseCardSheet(rows: readonly (readonly unknown[])[]): ProposedRow[] {
const out: ProposedRow[] = [];
const key = keyer();
rows.slice(1).forEach((r, i) => {
const date = excelDate(r[0]);
const amount = toMinor(r[7]);
if (!date || amount === null || amount === 0) return;
const cut = excelDate(r[1]);
const merchant = text(r[2]);
const category = text(r[3]);
const inst = parseInstallment(r[6]);
const refund = amount < 0;
const mapped = CARD_CATEGORY[fold(category)];
const isCampaign = fold(category) === "kampanya iadesi";
const group = groupOf(r[4]);
let review: string | null = null;
let categoryKey: string | null;
let kind: RowKind;
if (refund || isCampaign) {
kind = "income";
categoryKey = "iade";
} else {
kind = "expense";
categoryKey = mapped ? refineCategory(mapped, merchant) : "diger-gider";
if (!mapped) review = "unknownCategory";
else if (categoryKey === "diger-gider" && !group) review = "ungrouped";
}
// Taksit: ekstreye yansıdığı kesim tarihinde; plan anahtarı işyeri + toplam + adet.
const effectiveDate = inst && cut ? cut : date;
out.push({
sheet: "card",
line: i + 1,
date: effectiveDate,
statementCut: cut,
description: merchant,
amountMinor: -amount,
kind,
categoryKey,
kakeiboGroup: kind === "expense" ? group : null,
counterRole: null,
installment: inst
? { key: `${fold(merchant)}|${inst.totalMinor}|${inst.count}`, ...inst }
: null,
source: { category, type: text(r[5]) },
review,
key: key(`card|${effectiveDate}|${amount}|${fold(merchant)}`),
});
});
return out;
}
// --- Maaş hesabı sayfası ---
type BankRule =
| { kind: "income" | "expense"; categoryKey: string }
| { kind: "transfer"; counterRole: ImportRole }
| { kind: "deferred"; reason: string }
| { kind: "opening" }
| { kind: "promo" };
const BANK_TYPE: Record<string, BankRule> = {
acilis: { kind: "opening" },
maas: { kind: "income", categoryKey: "maas" },
"prim (maas farki)": { kind: "income", categoryKey: "kakeibo.prim" },
"yol parasi": { kind: "income", categoryKey: "kakeibo.yol" },
avans: { kind: "income", categoryKey: "kakeibo.avans" },
"es katkisi": { kind: "income", categoryKey: "kakeibo.es-katkisi" },
"aile destegi": { kind: "income", categoryKey: "kakeibo.aile-destegi" },
"diger giris": { kind: "income", categoryKey: "diger-gelir" },
promosyon: { kind: "promo" },
"kredi karti odemesi": { kind: "transfer", counterRole: "card" },
"nakit cekim (atm)": { kind: "transfer", counterRole: "cash" },
"hesaplar arasi": { kind: "transfer", counterRole: "extra" },
altin: { kind: "deferred", reason: "gold" },
"kmh faiz ve vergi": { kind: "expense", categoryKey: "banka.faiz" },
"banka ucreti": { kind: "expense", categoryKey: "banka.ucret" },
"banka karti harcamasi": { kind: "expense", categoryKey: "diger-gider" },
"havale/eft (kisi)": { kind: "expense", categoryKey: "diger-gider" },
"ev bakim/onarim": { kind: "expense", categoryKey: "ev" },
"cocuk ve okul": { kind: "expense", categoryKey: "cocuk" },
aidat: { kind: "expense", categoryKey: "aidat" },
};
/** Banka kartı ve havale açıklamasından kategori önerisi. */
export function bankExpenseCategory(type: string, description: string): string {
const d = fold(description);
const t = fold(type);
if (t === "kmh faiz ve vergi") return /kkdf|bsmv/.test(d) ? "banka.vergi" : "banka.faiz";
if (/borekci|borek|simit|pastane|otomat|kahve|bufe/.test(d)) return "eglence.kafe";
if (/market/.test(d)) return "market.gida";
if (/saglik|eczane/.test(d)) return "saglik";
if (/istanbulkart/.test(d)) return "ulasim.toplu-tasima";
if (/yemek/.test(d)) return "eglence.disarida-yemek";
return "diger-gider";
}
export type BankSheet = {
rows: ProposedRow[];
/** Açılış satırının bakiyesi ve tarihi (dosyada varsa). */
opening: { date: string; balanceMinor: number } | null;
/** Bakiye sütununa göre zincir kontrolü: ilk uyuşmayan satır (yoksa null). */
balanceMismatch: { line: number; expectedMinor: number; actualMinor: number } | null;
closingBalanceMinor: number | null;
};
export function parseBankSheet(rows: readonly (readonly unknown[])[]): BankSheet {
const out: ProposedRow[] = [];
const key = keyer();
let opening: BankSheet["opening"] = null;
let running: number | null = null;
let mismatch: BankSheet["balanceMismatch"] = null;
let closing: number | null = null;
const parsed = rows
.slice(1)
.map((r, i) => ({ r, line: i + 1 }))
.filter(({ r }) => excelDate(r[0]));
// Aynı tutarda giriş/çıkışı olan promosyon çiftleri net etkisizdir (atlanır).
const promo = parsed.filter(({ r }) => fold(text(r[2])) === "promosyon");
const promoNet = promo.reduce((a, { r }) => a + (toMinor(r[3]) ?? 0), 0);
for (const { r, line } of parsed) {
const date = excelDate(r[0])!;
const type = text(r[2]);
const desc = text(r[1]);
const amount = toMinor(r[3]);
const balance = toMinor(r[4]);
const rule = BANK_TYPE[fold(type)];
if (rule?.kind === "opening") {
if (balance !== null) {
opening = { date, balanceMinor: balance };
running = balance;
}
continue;
}
if (amount === null || amount === 0) continue;
if (running !== null) running += amount;
if (balance !== null) {
if (running !== null && running !== balance && !mismatch)
mismatch = { line, expectedMinor: running, actualMinor: balance };
running = balance;
closing = balance;
}
let kind: RowKind;
let categoryKey: string | null = null;
let counterRole: ImportRole | null = null;
let review: string | null = null;
if (!rule) {
kind = amount > 0 ? "income" : "expense";
categoryKey = amount > 0 ? "diger-gelir" : "diger-gider";
review = "unknownType";
} else if (rule.kind === "promo") {
if (promoNet === 0) kind = "skip";
else {
kind = amount > 0 ? "income" : "expense";
categoryKey = amount > 0 ? "ek-gelir" : "diger-gider";
review = "promo";
}
} else if (rule.kind === "transfer") {
kind = "transfer";
counterRole = rule.counterRole;
} else if (rule.kind === "deferred") {
kind = "deferred";
counterRole = "gold";
} else {
kind = rule.kind;
categoryKey =
rule.kind === "expense" && (rule.categoryKey === "diger-gider" || type.startsWith("KMH"))
? bankExpenseCategory(type, desc)
: rule.categoryKey;
// Yön uyuşmazlığı (ör. gider türünde giriş): kullanıcı baksın.
if ((rule.kind === "income") !== amount > 0) review = "signMismatch";
else if (categoryKey === "diger-gider") review = "ungrouped";
}
out.push({
sheet: "bank",
line,
date,
statementCut: null,
description: desc,
amountMinor: amount,
kind,
categoryKey,
kakeiboGroup: null,
counterRole,
installment: null,
source: { category: "", type },
review,
key: key(`bank|${date}|${amount}|${fold(desc)}`),
});
}
return { rows: out, opening, balanceMismatch: mismatch, closingBalanceMinor: closing };
}
// --- Özet: ekstre toplamları ---
/** "Özet" sayfasının 1. bölümünden ekstre kesimi → ekstredeki harcama (kuruş). */
export function parseStatementTotals(rows: readonly (readonly unknown[])[]): Map<string, number> {
const out = new Map<string, number>();
const start = rows.findIndex((r) => fold(text(r[0])) === "ekstre kesim");
if (start < 0) return out;
for (const r of rows.slice(start + 1)) {
const cut = excelDate(r[0]);
if (!cut) break;
const total = toMinor(r[6]);
if (total !== null) out.set(cut, total);
}
return out;
}
export type StatementCheck = {
cut: string;
rowsMinor: number;
statementMinor: number | null;
ok: boolean;
};
/** Her ekstre için satır toplamı (harcama − iade) = ekstredeki harcama. */
export function checkStatements(
rows: readonly ProposedRow[],
totals: ReadonlyMap<string, number>,
): StatementCheck[] {
const sums = new Map<string, number>();
for (const r of rows)
if (r.sheet === "card" && r.statementCut)
sums.set(r.statementCut, (sums.get(r.statementCut) ?? 0) - r.amountMinor);
return [...sums.entries()]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([cut, rowsMinor]) => {
const statementMinor = totals.get(cut) ?? null;
return { cut, rowsMinor, statementMinor, ok: statementMinor === rowsMinor };
});
}
/** Banka hesabından karta yapılan ilk ödeme: kartın açılış borcu önerisi (kuruş, pozitif). */
export function firstCardPayment(
rows: readonly ProposedRow[],
): { date: string; amountMinor: number } | null {
const r = rows.find(
(x) => x.sheet === "bank" && x.kind === "transfer" && x.counterRole === "card",
);
return r ? { date: r.date, amountMinor: -r.amountMinor } : null;
}
BIN
View File
Binary file not shown.