diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3606e..24bac95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 020273f..5b09f62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package-lock.json b/package-lock.json index 69118eb..526b89a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6a2590..0120e41 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/migrations/017-imports.ts b/scripts/migrations/017-imports.ts new file mode 100644 index 0000000..1cf2169 --- /dev/null +++ b/scripts/migrations/017-imports.ts @@ -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(() => {}); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 182bb00..cf532cb 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -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"; diff --git a/src/app/(app)/islemler/ice-aktar/[id]/page.tsx b/src/app/(app)/islemler/ice-aktar/[id]/page.tsx new file mode 100644 index 0000000..283f954 --- /dev/null +++ b/src/app/(app)/islemler/ice-aktar/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/islemler/ice-aktar/page.tsx b/src/app/(app)/islemler/ice-aktar/page.tsx new file mode 100644 index 0000000..0395834 --- /dev/null +++ b/src/app/(app)/islemler/ice-aktar/page.tsx @@ -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 ( + <> + + + +
+

+ {t.history} +

+ {list.length ? ( +
    + {list.map((b) => ( +
  • + + + {b.fileName} + + {formatDateTime(b.createdAt)} · {t.rowsCount(b.rowCount)} + {b.committed ? ` · ${t.committed(b.committed.transactions)}` : ""} + + + + {t.statuses[b.status]} + + + +
  • + ))} +
+ ) : ( +

+ {t.empty} +

+ )} +
+ + ); +} diff --git a/src/features/imports/actions.ts b/src/features/imports/actions.ts new file mode 100644 index 0000000..027849d --- /dev/null +++ b/src/features/imports/actions.ts @@ -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(r: ActionResult) { + 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)); +} diff --git a/src/features/imports/components/import-wizard.tsx b/src/features/imports/components/import-wizard.tsx new file mode 100644 index 0000000..9ff8d21 --- /dev/null +++ b/src/features/imports/components/import-wizard.tsx @@ -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 = { + 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 ( +
+

{title}

+ {children} +
+ ); +} + +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 ( +
  • +
    +
    +

    + {current ? ( + + ) : ( + + )} + {t.roles[role]} +

    +

    {t.roleHints[role]}

    +
    + + {draft.canCreateAccounts && preset && !current ? ( + + ) : null} +
    + {creating && preset ? ( +
    + + {t.accountName} + setV((s) => ({ ...s, name: e.target.value }))} + /> + + + + {isCard ? t.openingDebt : t.openingBalance} + +
    + {!isCard ? ( + + ) : null} + setV((s) => ({ ...s, opening: e.target.value }))} + className="text-right tabular-nums" + /> +
    +
    + + {t.openingDate} + setV((s) => ({ ...s, openingDate: e.target.value }))} + /> + + + {isCard ? t.openingHintCard : role === "bank" ? t.openingHintBank : ""} + + +
    + ) : null} +
  • + ); +} + +function RowItem({ + row, + draft, + onEdit, +}: { + row: ImportRowView; + draft: ImportDraft; + onEdit: ( + row: ImportRowView, + patch: Partial>, + ) => 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 ( +
  • +
    + {day(row.date)} + + {row.description || "—"} + + {row.installment ? ( + + {t.installment(row.installment.index, row.installment.count)} + + ) : null} + {row.duplicate ? ( + + + {t.duplicate} + + ) : null} + {row.review && !row.duplicate ? ( + + + {t.reviews[row.review] ?? row.review} + + ) : null} + {row.source.category || row.source.type ? ( + + {row.source.category || row.source.type} + + ) : null} + + + + {row.amountMinor < 0 ? "−" : "+"} + {formatMoney(Math.abs(row.amountMinor))} + +
    + {row.kind === "transfer" ? ( +

    + {t.kinds.transfer}{" "} + {row.amountMinor < 0 + ? t.transferTo(t.roles[row.counterRole ?? ""] ?? "") + : t.transferFrom(t.roles[row.counterRole ?? ""] ?? "")} +

    + ) : row.kind === "deferred" ? ( +

    + {t.kinds.deferred}: {t.roles.gold} +

    + ) : editable ? ( +
    + + {row.kind !== "skip" ? ( + + ) : ( + + )} + {row.kind === "expense" ? ( + + onEdit(row, { kakeiboGroup: (g || cat?.group || null) as KakeiboGroup | null }) + } + suggested={cat?.group ?? null} + bankCost={cat?.bankCost ?? false} + ariaLabel={t.columns.group} + /> + ) : null} +
    + ) : null} +
  • + ); +} + +/** /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("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>, + ) { + 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 ( +
    + + + {t.statuses[draft.status]} · {t.rowsCount(draft.rowCount)} + + } + /> + + {!isDraft ? ( + + {draft.committed ? ( +

    + {t.committedSummary( + draft.committed.transactions, + draft.committed.skipped, + draft.committed.categoriesCreated, + )} +

    + ) : null} +
    + + {draft.status === "committed" ? ( + + ) : null} +
    +
    + ) : null} + + {isDraft && draft.neededRoles.length ? ( + + {!draft.canCreateAccounts ? ( +

    {t.ownerOnly}

    + ) : null} +
      + {draft.neededRoles.map((role) => ( + + ))} +
    +
    + ) : null} + + +
      + {draft.checks.statements.length ? ( +
    • + {okStatements === draft.checks.statements.length ? ( + + ) : ( + + )} + + {t.statementsSummary(okStatements, draft.checks.statements.length)} + {draft.checks.statements + .filter((s) => !s.ok) + .map((s) => ( + + {t.statementBad( + day(s.cut), + formatMoney(s.rowsMinor), + s.statementMinor === null ? "—" : formatMoney(s.statementMinor), + )} + + ))} + +
    • + ) : ( +
    • {t.noStatements}
    • + )} + {draft.checks.balance.closingMinor !== null ? ( +
    • + {draft.checks.balance.mismatchLine === null ? ( + + ) : ( + + )} + {draft.checks.balance.mismatchLine === null + ? t.balanceOk(formatMoney(draft.checks.balance.closingMinor)) + : t.balanceBad(draft.checks.balance.mismatchLine)} +
    • + ) : null} + {duplicates ? ( +
    • {t.duplicates(duplicates)}
    • + ) : null} + {deferred ? ( +
    • + {t.deferred( + deferred, + `${deferredNet < 0 ? "−" : "+"}${formatMoney(Math.abs(deferredNet))}`, + )} +
    • + ) : null} + {isDraft && draft.newCategories.length ? ( +
    • + {t.newCategories(draft.newCategories.join(", "))} +
    • + ) : null} +
    +
    + + +
    + { + setSheet(x as "card" | "bank"); + setPage(0); + }} + > + + {(["card", "bank"] as const) + .filter((s) => rows.some((r) => r.sheet === s)) + .map((s) => ( + + {t.sheets[s]} ({rows.filter((r) => r.sheet === s).length}) + + ))} + + +
    + {filters.map((f) => ( + + ))} +
    + {isDraft ? ( + + ) : null} +
    +
      + {pageRows.map((r) => ( + + ))} + {!pageRows.length ? ( +
    • {t.empty}
    • + ) : null} +
    + {visible.length > PAGE ? ( +
    + + {t.page(page * PAGE + 1, Math.min(visible.length, (page + 1) * PAGE), visible.length)} + + + +
    + ) : null} +
    + + {isDraft ? ( + +

    + {t.commitSummary(toCreate.length, rows.length - toCreate.length)} +

    + {missingRoles.map((r) => ( +

    + {t.errors.roleMissing(t.roles[r] ?? r)} +

    + ))} + {missingCategory.length ? ( +

    + {t.errors.category(String(missingCategory.length))} +

    + ) : null} +

    {t.commitHint}

    +
    + + +
    +
    + ) : null} + + + + + {t.revertTitle} + {t.revertDescription} + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/imports/components/upload-card.tsx b/src/features/imports/components/upload-card.tsx new file mode 100644 index 0000000..797a444 --- /dev/null +++ b/src/features/imports/components/upload-card.tsx @@ -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(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 ( +
    + + + +

    {t.uploadHint}

    + onPick(e.target.files?.[0])} + /> + +
    + ); +} diff --git a/src/features/imports/service.int.test.ts b/src/features/imports/service.int.test.ts new file mode 100644 index 0000000..b5bcc47 --- /dev/null +++ b/src/features/imports/service.int.test.ts @@ -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>; +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 = {}; + + 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); + }); +}); diff --git a/src/features/imports/service.ts b/src/features/imports/service.ts new file mode 100644 index 0000000..4a45263 --- /dev/null +++ b/src/features/imports/service.ts @@ -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 = { + 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>; + 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>; + /** Satırların ihtiyaç duyduğu roller. */ + neededRoles: ImportRole[]; + accountOptions: { id: string; name: string; type: AccountType }[]; + presets: Record; + 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(); + 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) => + 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) => + 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(); + 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(); +} + +// --- Okuma --- + +export async function listImports( + userId: string, + householdId: string, +): Promise { + 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 & { 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 { + 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 = { + 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> { + 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 { + 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> { + 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> { + 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 = { "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> { + 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> = {}; + 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(); + 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(); + const docs: Record[] = []; + 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) { + 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> { + 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 { + 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); +} diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx index 4983c64..82e7a6f 100644 --- a/src/features/transactions/components/transactions-view.tsx +++ b/src/features/transactions/components/transactions-view.tsx @@ -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({ {t.export} + {canWrite ? ( + + ) : null} {canWrite ? (