From 8dd5fd42e3ec30d9213cb21027a5f5ef76bc8178 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:57:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20hesaplar=20(KMH,=20kredi=20ka?= =?UTF-8?q?rt=C4=B1,=20yemek=20kart=C4=B1,=20yaln=C4=B1zca=20ben)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/hesaplar/page.tsx | 29 + src/features/accounts/actions.ts | 75 ++ .../components/account-form-dialog.tsx | 678 ++++++++++++++++++ .../accounts/components/accounts-view.tsx | 315 ++++++++ src/features/accounts/service.int.test.ts | 156 ++++ src/features/accounts/service.ts | 376 ++++++++++ src/models/account.ts | 67 ++ src/shared/accounts.ts | 20 + src/shared/schemas/account.ts | 89 +++ 9 files changed, 1805 insertions(+) create mode 100644 src/app/(app)/hesaplar/page.tsx create mode 100644 src/features/accounts/actions.ts create mode 100644 src/features/accounts/components/account-form-dialog.tsx create mode 100644 src/features/accounts/components/accounts-view.tsx create mode 100644 src/features/accounts/service.int.test.ts create mode 100644 src/features/accounts/service.ts create mode 100644 src/models/account.ts create mode 100644 src/shared/accounts.ts create mode 100644 src/shared/schemas/account.ts diff --git a/src/app/(app)/hesaplar/page.tsx b/src/app/(app)/hesaplar/page.tsx new file mode 100644 index 0000000..097a2aa --- /dev/null +++ b/src/app/(app)/hesaplar/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { AccountsView } from "@/features/accounts/components/accounts-view"; +import { listAccounts, listMemberOptions } from "@/features/accounts/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.accounts.title }; + +export default async function AccountsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const [data, members] = await Promise.all([ + listAccounts(user.id, household.id, { includeArchived: true }), + listMemberOptions(user.id, household.id), + ]); + if (!data || !members) notFound(); + return ( + <> + + + + ); +} diff --git a/src/features/accounts/actions.ts b/src/features/accounts/actions.ts new file mode 100644 index 0000000..fa6137a --- /dev/null +++ b/src/features/accounts/actions.ts @@ -0,0 +1,75 @@ +"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 { accountFormSchema } from "@/shared/schemas/account"; +import { createAccount, reorderAccount, setAccountArchived, updateAccount } from "./service"; + +const PATH = "/hesaplar"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +/** zod hatalarını alan bazlı mesajlara çevirir (formda gösterilir). */ +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = String(issue.path[0] ?? ""); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +export async function createAccountAction(input: unknown): Promise> { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = accountFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await createAccount(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function updateAccountAction( + accountId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const parsed = accountFormSchema.safeParse(input); + if (!id.success) return fail(tr.common.notFound); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await updateAccount(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function setAccountArchivedAction( + accountId: unknown, + archived: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const a = z.boolean().safeParse(archived); + if (!id.success || !a.success) return fail(tr.common.genericError); + const result = await setAccountArchived(ctx.user.id, ctx.household.id, id.data, a.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function reorderAccountAction( + accountId: unknown, + direction: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const dir = z.enum(["up", "down"]).safeParse(direction); + if (!id.success || !dir.success) return fail(tr.common.genericError); + const result = await reorderAccount(ctx.user.id, ctx.household.id, id.data, dir.data); + if (result.ok) revalidatePath(PATH); + return result; +} diff --git a/src/features/accounts/components/account-form-dialog.tsx b/src/features/accounts/components/account-form-dialog.tsx new file mode 100644 index 0000000..879f418 --- /dev/null +++ b/src/features/accounts/components/account-form-dialog.tsx @@ -0,0 +1,678 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Controller, useForm, useWatch, type Control, type Resolver } from "react-hook-form"; +import { toast } from "sonner"; +import { IconPicker, SwatchPicker } from "@/components/appearance/pickers"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { + ACCOUNT_TYPES, + DUE_RULE_KINDS, + MEAL_CARD_PROVIDERS, + type AccountType, +} from "@/shared/accounts"; +import type { IconKey } from "@/shared/appearance"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { + accountFormSchema, + type AccountForm, + type AccountFormInput, +} from "@/shared/schemas/account"; +import { createAccountAction, updateAccountAction } from "../actions"; +import type { AccountView } from "../service"; + +const t = tr.accounts; +const f = t.fields; +const NONE = "__none__"; +const DAYS = Array.from({ length: 31 }, (_, i) => i + 1); + +const DEFAULT_ICON: Record = { + cash: "wallet", + bank: "landmark", + savings: "piggy-bank", + credit_card: "credit-card", + meal_card: "utensils", +}; + +type FormValues = { + name: string; + type: AccountType; + institution: string; + mealCardProvider: AccountFormInput["mealCardProvider"]; + openingBalance: string; + openingDate: string; + color: AccountFormInput["color"]; + icon: AccountFormInput["icon"]; + ownerUserId: string; + isPrivate: boolean; + overdraftEnabled: boolean; + overdraftLimit: string; + overdraftMonthlyRate: string; + overdraftStatementDay: string; + cardLimit: string; + cardStatementDay: string; + cardDueKind: AccountFormInput["cardDueKind"]; + cardDueValue: string; + cardPaymentAccountId: string; +}; + +const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : ""); + +function toValues(a?: AccountView): FormValues { + if (!a) { + return { + name: "", + type: "bank", + institution: "", + mealCardProvider: null, + openingBalance: "", + openingDate: todayIstanbul(), + color: "blue", + icon: "landmark", + ownerUserId: "", + isPrivate: false, + overdraftEnabled: false, + overdraftLimit: "", + overdraftMonthlyRate: "", + overdraftStatementDay: "", + cardLimit: "", + cardStatementDay: "1", + cardDueKind: "daysAfter", + cardDueValue: "10", + cardPaymentAccountId: "", + }; + } + const isCard = a.type === "credit_card"; + return { + name: a.name, + type: a.type, + institution: a.institution, + mealCardProvider: a.mealCardProvider, + openingBalance: + a.openingBalanceMinor === null + ? "" + : money(isCard ? Math.abs(a.openingBalanceMinor) : a.openingBalanceMinor), + openingDate: a.openingDate, + color: a.color, + icon: a.icon, + ownerUserId: a.ownerUserId ?? "", + isPrivate: a.isPrivate, + overdraftEnabled: Boolean(a.overdraft), + overdraftLimit: money(a.overdraft?.limitMinor), + overdraftMonthlyRate: a.overdraft?.monthlyRateBp + ? formatMoneyInput(a.overdraft.monthlyRateBp) + : "", + overdraftStatementDay: a.overdraft?.statementDay ? String(a.overdraft.statementDay) : "", + cardLimit: money(a.creditCard?.limitMinor), + cardStatementDay: String(a.creditCard?.statementDay ?? 1), + cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter", + cardDueValue: String(a.creditCard?.dueRule.value ?? 10), + cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "", + }; +} + +function DaySelect({ + id, + value, + onChange, + max = 31, + allowEmpty, +}: { + id: string; + value: string; + onChange: (v: string) => void; + max?: number; + allowEmpty?: boolean; +}) { + return ( + + ); +} + +function MoneyField({ + control, + name, + label, + hint, + allowNegative, + disabled, +}: { + control: Control; + name: "openingBalance" | "overdraftLimit" | "cardLimit"; + label: string; + hint?: string; + allowNegative?: boolean; + disabled?: boolean; +}) { + return ( + ( + + {label} + + {hint ? {hint} : null} + {fieldState.invalid ? : null} + + )} + /> + ); +} + +export function AccountFormDialog({ + open, + account, + members, + paymentAccounts, + onOpenChange, +}: { + open: boolean; + account?: AccountView; + members: { id: string; name: string }[]; + paymentAccounts: { id: string; name: string }[]; + onOpenChange: (open: boolean) => void; +}) { + const isEdit = Boolean(account); + const balanceHidden = isEdit && account!.openingBalanceMinor === null; + const form = useForm({ + // FormValues, şemanın giriş şekliyle birebir (sayısal alanlar string olarak girilir, zod çevirir). + resolver: zodResolver(accountFormSchema) as unknown as Resolver< + FormValues, + unknown, + AccountForm + >, + defaultValues: toValues(), + }); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + if (open) form.reset(toValues(account)); + }, [open, account, form]); + + function handleOpenChange(next: boolean) { + if (!next) setAdvancedOpen(false); + onOpenChange(next); + } + + const type = useWatch({ control: form.control, name: "type" }); + const color = useWatch({ control: form.control, name: "color" }); + const overdraftEnabled = useWatch({ control: form.control, name: "overdraftEnabled" }); + const dueKind = useWatch({ control: form.control, name: "cardDueKind" }); + const isCard = type === "credit_card"; + + /** Ham form değerleri gönderilir; sunucu aynı şemayla ayrıştırır. */ + async function onSubmit() { + const values = form.getValues(); + // Gizli bakiyede sunucu mevcut değeri korur; doğrulamadan geçmesi için 0 gönderilir. + const payload = balanceHidden ? { ...values, openingBalance: "0" } : values; + const result = account + ? await updateAccountAction(account.id, payload) + : await createAccountAction(payload); + if (result.ok) { + toast.success(account ? t.updated(values.name) : t.created(values.name)); + handleOpenChange(false); + return; + } + let advancedError = false; + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (!message) continue; + form.setError(name as keyof FormValues, { message }); + if (/^(overdraft|ownerUserId|isPrivate)/.test(name)) advancedError = true; + } + if (advancedError) setAdvancedOpen(true); + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + return ( + + + + + } + > +
+ + ( + + {f.type} + + {isEdit ? {f.typeLockedHint} : null} + + )} + /> + ( + + {f.name} + + {fieldState.invalid ? : null} + + )} + /> + {type === "meal_card" ? ( + ( + + {f.provider} + + {fieldState.invalid ? : null} + + )} + /> + ) : type !== "cash" ? ( + ( + + {f.institution} + + + )} + /> + ) : null} +
+ + ( + + {f.openingDate} + + {fieldState.invalid ? : null} + + )} + /> +
+ + {isCard ? ( +
+ {f.card} + + + ( + + {f.cardStatementDay} + + {f.cardStatementDayHint} + + )} + /> +
+ ( + + {f.cardDueKind} + + + )} + /> + ( + + + {dueKind === "daysAfter" ? f.cardDueValueDays : f.cardDueValueDay} + + + {fieldState.invalid ? : null} + + )} + /> +
+ ( + + {f.cardPaymentAccount} + + {fieldState.invalid ? : null} + + )} + /> +
+
+ ) : null} + + + + + + + + {type === "bank" ? ( +
+ {f.overdraft} + + ( + + field.onChange(v === true)} + /> + + {f.overdraftEnabled} + + + )} + /> + {overdraftEnabled ? ( + <> + +
+ ( + + {f.overdraftRate} + + {fieldState.invalid ? ( + + ) : null} + + )} + /> + ( + + + {f.overdraftStatementDay} + + + + )} + /> +
+ + ) : null} +
+
+ ) : null} + + ( + + {f.owner} + + {fieldState.invalid ? : null} + + )} + /> + ( + + field.onChange(v === true)} + /> + + {f.isPrivate} + + + )} + /> + ( + + {tr.categories.color} + + + )} + /> + ( + + {tr.categories.icon} + + + )} + /> +
+
+
+ {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} +
+
+
+ ); +} diff --git a/src/features/accounts/components/accounts-view.tsx b/src/features/accounts/components/accounts-view.tsx new file mode 100644 index 0000000..eec7c68 --- /dev/null +++ b/src/features/accounts/components/accounts-view.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowDown, + ArrowUp, + ChevronDown, + EyeOff, + MoreHorizontal, + Pencil, + Plus, +} from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { ACCOUNT_TYPES, isLiabilityType } from "@/shared/accounts"; +import { formatMoney } from "@/shared/format"; +import { reorderAccountAction, setAccountArchivedAction } from "../actions"; +import type { AccountTotals, AccountView } from "../service"; +import { AccountFormDialog } from "./account-form-dialog"; + +const t = tr.accounts; + +function BalanceText({ account }: { account: AccountView }) { + if (account.balanceMinor === null) { + return ( + + + {t.hidden} + + ); + } + const liability = isLiabilityType(account.type); + const value = account.balanceMinor; + return ( + + {liability ? {t.debt} : null} + + {formatMoney(liability ? Math.abs(value) : value)} + + + ); +} + +export function AccountsView({ + accounts, + totals, + members, + canEdit, +}: { + accounts: AccountView[]; + totals: AccountTotals; + members: { id: string; name: string }[]; + canEdit: boolean; +}) { + const [dialog, setDialog] = useState<{ account?: AccountView } | null>(null); + const [archiving, setArchiving] = useState(null); + const [pending, startTransition] = useTransition(); + const active = accounts.filter((a) => !a.archived); + const archived = accounts.filter((a) => a.archived); + const memberName = (id: string | null) => members.find((m) => m.id === id)?.name; + const paymentAccounts = active + .filter((a) => ["bank", "cash", "savings"].includes(a.type)) + .map((a) => ({ id: a.id, name: a.name })); + + function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) { + startTransition(async () => { + const r = await promise; + if (r.ok) { + if (success) toast.success(success); + } else toast.error(r.error ?? tr.common.genericError); + }); + } + + function Row({ a, index }: { a: AccountView; index: number }) { + const owner = memberName(a.ownerUserId); + return ( +
  • + +
    +

    {a.name}

    +
    + {a.type === "meal_card" && a.mealCardProvider ? ( + {t.providers[a.mealCardProvider]} + ) : null} + {a.institution ? {a.institution} : null} + {owner ? · {owner} : null} + {a.isPrivate ? {t.privateBadge} : null} + {a.overdraft ? ( + + {t.overdraftBadge(formatMoney(a.overdraft.limitMinor))} + + ) : null} + {a.creditCard ? ( + <> + + {t.limitLabel(formatMoney(a.creditCard.limitMinor))} + + {t.statementLabel(a.creditCard.statementDay)} + + ) : null} +
    +
    + + {canEdit ? ( + + + + + + {a.archived ? ( + + run(setAccountArchivedAction(a.id, false), t.unarchivedToast(a.name)) + } + > + + {t.unarchive} + + ) : ( + <> + setDialog({ account: a })}> + + {tr.common.edit} + + run(reorderAccountAction(a.id, "up"))} + > + + {tr.categories.moveUp} + + run(reorderAccountAction(a.id, "down"))} + > + + {tr.categories.moveDown} + + + setArchiving(a)}> + + {t.archive} + + + )} + + + ) : null} +
  • + ); + } + + const summary = [ + { label: t.summary.assets, value: totals.assetsMinor, tone: "text-income" }, + { + label: t.summary.cardDebt, + value: Math.abs(totals.cardDebtMinor), + tone: totals.cardDebtMinor ? "text-expense" : "", + }, + { + label: t.summary.net, + value: totals.netMinor, + tone: totals.netMinor < 0 ? "text-expense" : "", + }, + ]; + + return ( +
    +
    + {summary.map((s) => ( + + + {s.label} + + {formatMoney(s.value)} + + + + ))} +
    + {totals.includesHidden || totals.excludesHidden ? ( +

    + {totals.excludesHidden ? t.summary.excludesHidden : t.summary.includesHidden} +

    + ) : null} + +
    + {!canEdit ?

    {t.ownerOnly}

    : null} + {canEdit ? ( + + ) : null} +
    + + {active.length === 0 ? ( +
    +

    {t.empty}

    + {canEdit ?

    {t.emptyOwnerHint}

    : null} +
    + ) : ( + ACCOUNT_TYPES.map((type) => { + const group = active.filter((a) => a.type === type); + if (!group.length) return null; + return ( +
    +

    + {t.typeGroups[type]} +

    +
      + {group.map((a) => ( + + ))} +
    +
    + ); + }) + )} + + {archived.length ? ( + + + + + +
      + {archived.map((a) => ( + + ))} +
    +
    +
    + ) : null} + + !o && setDialog(null)} + /> + + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.name) : null} + + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/accounts/service.int.test.ts b/src/features/accounts/service.int.test.ts new file mode 100644 index 0000000..1db1033 --- /dev/null +++ b/src/features/accounts/service.int.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { AccountModel } from "@/models/account"; +import { HouseholdModel } from "@/models/household"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { createAccount, listAccounts, setAccountArchived, updateAccount } from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); +}); +afterAll(teardownDb); + +const base = { + name: "Test Hesap", + type: "bank" as const, + institution: "", + mealCardProvider: null, + openingBalance: 100_00, + openingDate: "2026-05-01", + color: "blue" as const, + icon: "landmark" as const, + ownerUserId: null, + isPrivate: false, + overdraftEnabled: false, + overdraftLimit: 0, + overdraftMonthlyRate: null, + overdraftStatementDay: null, + cardLimit: 0, + cardStatementDay: 1, + cardDueKind: "daysAfter" as const, + cardDueValue: 10, + cardPaymentAccountId: null, +}; + +const accountId = (name: string, householdId = cakir) => + AccountModel.findOne({ householdId, name }) + .lean() + .then((a) => String(a!._id)); + +describe("roller ve izolasyon", () => { + it("yalnızca sahip hesap oluşturur", async () => { + expect((await createAccount(ids.user("emre"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("zeynep"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("fatma"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("ayse"), cakir, base)).ok).toBe(true); + }); + + it("başka hanenin hesaplarını göremez ve değiştiremez", async () => { + expect(await listAccounts(ids.user("fatma"), cakir)).toBeNull(); + const cuzdan = await accountId("Cüzdan"); + expect( + ( + await updateAccount(ids.user("fatma"), annemler, cuzdan, { + ...base, + type: "cash", + name: "X", + }) + ).ok, + ).toBe(false); + expect((await setAccountArchived(ids.user("fatma"), annemler, cuzdan, true)).ok).toBe(false); + const own = (await listAccounts(ids.user("fatma"), annemler))!.accounts.map((a) => a.name); + expect(own.sort()).toEqual(["Nakit", "Vakıfbank Emekli"]); + }); + + it("başka hanenin hesabı kart ödeme hesabı veya hesap sahibi başka hanenin üyesi olamaz", async () => { + const vakif = await accountId("Vakıfbank Emekli", annemler); + const card = { + ...base, + type: "credit_card" as const, + name: "Sızma Kart", + cardPaymentAccountId: vakif, + }; + expect((await createAccount(ids.user("ayse"), cakir, card)).ok).toBe(false); + expect( + (await createAccount(ids.user("ayse"), cakir, { ...base, ownerUserId: ids.user("fatma") })) + .ok, + ).toBe(false); + }); +}); + +describe("“yalnızca ben” hesabı", () => { + it("bakiyeyi yalnızca hesap sahibi görür (hane sahibi dahil diğerleri göremez)", async () => { + const find = async (user: string) => + (await listAccounts(ids.user(user), cakir))!.accounts.find( + (a) => a.name === "Ayşe — Pluxee", + )!; + expect((await find("ayse")).balanceMinor).toBe(3_120_00); + expect((await find("admin")).balanceMinor).toBeNull(); + expect((await find("emre")).balanceMinor).toBeNull(); + expect((await find("emre")).openingBalanceMinor).toBeNull(); + }); + + it("toplamlar hane ayarına uyar", async () => { + const withHidden = (await listAccounts(ids.user("emre"), cakir))!.totals; + expect(withHidden.includesHidden).toBe(true); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": false } }, + ); + const without = (await listAccounts(ids.user("emre"), cakir))!.totals; + expect(without.excludesHidden).toBe(true); + expect(withHidden.assetsMinor - without.assetsMinor).toBe(3_120_00); + // Hesap sahibi kendi hesabını her zaman toplamda görür. + const ayse = (await listAccounts(ids.user("ayse"), cakir))!.totals; + expect(ayse.assetsMinor).toBe(withHidden.assetsMinor); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": true } }, + ); + }); + + it("bakiyeyi göremeyen sahip düzenlediğinde gizli bakiye korunur", async () => { + const id = await accountId("Ayşe — Pluxee"); + const r = await updateAccount(ids.user("admin"), cakir, id, { + ...base, + type: "meal_card", + mealCardProvider: "pluxee", + name: "Ayşe — Pluxee", + openingBalance: 0, + ownerUserId: ids.user("ayse"), + isPrivate: true, + }); + expect(r.ok).toBe(true); + expect((await AccountModel.findById(id).lean())?.openingBalanceMinor).toBe(3_120_00); + }); +}); + +describe("kart ve tür kuralları", () => { + it("kart açılış borcu negatif saklanır; tür değiştirilemez", async () => { + const payer = await accountId("Ziraat Maaş"); + const r = await createAccount(ids.user("ayse"), cakir, { + ...base, + type: "credit_card", + name: "Yeni Kart", + openingBalance: 1_500_00, + cardLimit: 20_000_00, + cardStatementDay: 31, + cardPaymentAccountId: payer, + }); + expect(r.ok).toBe(true); + const card = await AccountModel.findById(r.ok ? r.data.id : "").lean(); + expect(card?.openingBalanceMinor).toBe(-1_500_00); + expect(card?.creditCard?.statementDay).toBe(31); + expect(card?.creditCard?.paymentAccountId?.toString()).toBe(payer); + const change = await updateAccount(ids.user("ayse"), cakir, String(card!._id), { + ...base, + name: "Yeni Kart", + }); + expect(change.ok).toBe(false); + }); +}); diff --git a/src/features/accounts/service.ts b/src/features/accounts/service.ts new file mode 100644 index 0000000..4833842 --- /dev/null +++ b/src/features/accounts/service.ts @@ -0,0 +1,376 @@ +import "server-only"; +import { Types } from "mongoose"; +import { getHouseholdDetail, resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { diffFields, writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { AccountModel } from "@/models/account"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { + isLiabilityType, + type AccountType, + type DueRuleKind, + type MealCardProvider, +} from "@/shared/accounts"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; +import type { AccountForm } from "@/shared/schemas/account"; +import { compareTr } from "@/shared/search"; + +/** + * Hesap servisi. Okuma: viewer; oluşturma/düzenleme/arşiv/sıralama: owner (Bölüm 5). + * "Yalnızca ben" hesaplarında bakiye yalnızca hesap sahibi kişiye görünür. + * Bakiye işareti: varlık pozitif, kredi kartı borcu negatif. + */ + +const t = tr.accounts; + +export type AccountView = { + id: string; + name: string; + type: AccountType; + institution: string; + mealCardProvider: MealCardProvider | null; + color: Swatch; + icon: IconKey; + order: number; + archived: boolean; + isPrivate: boolean; + ownerUserId: string | null; + /** Bu kullanıcıya gizliyse null. */ + balanceMinor: number | null; + openingBalanceMinor: number | null; + openingDate: string; + overdraft: { + enabled: boolean; + limitMinor: number; + monthlyRateBp: number | null; + statementDay: number | null; + } | null; + creditCard: { + limitMinor: number; + statementDay: number; + dueRule: { kind: DueRuleKind; value: number }; + paymentAccountId: string | null; + } | null; +}; + +export type AccountTotals = { + /** Nakit + banka + birikim + yemek kartı (pozitif bakiyeler). */ + assetsMinor: number; + /** Kredi kartı borçları (negatif). */ + cardDebtMinor: number; + netMinor: number; + /** Toplamlarda bu kullanıcının bakiyesini göremediği hesap var mı? */ + includesHidden: boolean; + /** Gizli hesaplar hane ayarı gereği toplamlardan çıkarıldı mı? */ + excludesHidden: boolean; +}; + +type Lean = { + _id: Types.ObjectId; + name: string; + type: AccountType; + institution: string; + mealCardProvider: MealCardProvider | null; + color: Swatch; + icon: IconKey; + order: number; + archivedAt: Date | null; + isPrivate: boolean; + ownerUserId: Types.ObjectId | null; + openingBalanceMinor: number; + openingDate: Date; + overdraft: AccountView["overdraft"]; + creditCard: + | (Omit, "paymentAccountId"> & { + paymentAccountId: Types.ObjectId | null; + }) + | null; +}; + +const canSeeBalance = (a: Lean, viewerId: string) => + !a.isPrivate || String(a.ownerUserId) === viewerId; + +/** FAZ 6b: açılış bakiyesi + işlemler. Şimdilik açılış bakiyesi. */ +function computeBalance(a: Lean): number { + return a.openingBalanceMinor; +} + +function toView(a: Lean, viewerId: string): AccountView { + const visible = canSeeBalance(a, viewerId); + return { + id: String(a._id), + name: a.name, + type: a.type, + institution: a.institution ?? "", + mealCardProvider: a.mealCardProvider ?? null, + color: a.color, + icon: a.icon, + order: a.order, + archived: Boolean(a.archivedAt), + isPrivate: a.isPrivate, + ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null, + balanceMinor: visible ? computeBalance(a) : null, + openingBalanceMinor: visible ? a.openingBalanceMinor : null, + openingDate: toIstanbulYmd(a.openingDate), + overdraft: a.overdraft?.enabled ? a.overdraft : null, + creditCard: a.creditCard + ? { + ...a.creditCard, + paymentAccountId: a.creditCard.paymentAccountId + ? String(a.creditCard.paymentAccountId) + : null, + } + : null, + }; +} + +export async function listAccounts( + userId: string, + householdId: string, + opts: { includeArchived?: boolean } = {}, +): Promise<{ accounts: AccountView[]; totals: AccountTotals } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const filter: Record = { householdId: m.householdId }; + if (!opts.includeArchived) filter.archivedAt = null; + const rows = (await AccountModel.find(filter).lean()).sort( + (a, b) => a.order - b.order || compareTr(a.name, b.name), + ); + const household = await HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(); + const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true; + + const totals: AccountTotals = { + assetsMinor: 0, + cardDebtMinor: 0, + netMinor: 0, + includesHidden: false, + excludesHidden: false, + }; + for (const a of rows) { + if (a.archivedAt) continue; + const visible = canSeeBalance(a, userId); + if (!visible && !includePrivate) { + totals.excludesHidden = true; + continue; + } + if (!visible) totals.includesHidden = true; + const balance = computeBalance(a); + if (isLiabilityType(a.type)) totals.cardDebtMinor += balance; + else totals.assetsMinor += balance; + } + totals.netMinor = totals.assetsMinor + totals.cardDebtMinor; + return { accounts: rows.map((a) => toView(a, userId)), totals }; +} + +/** Form → belge alanları. Kart için açılış borcu negatif saklanır. */ +async function toDocFields( + householdId: Types.ObjectId, + input: AccountForm, + selfId?: Types.ObjectId, +): Promise>> { + const openingDate = istanbulDayStart(input.openingDate); + if (!openingDate) return fail(tr.validation.date, { openingDate: tr.validation.date }); + + let ownerUserId: Types.ObjectId | null = null; + if (input.ownerUserId) { + ownerUserId = toObjectId(input.ownerUserId); + const isMember = + ownerUserId && (await HouseholdMemberModel.exists({ householdId, userId: ownerUserId })); + if (!isMember) return fail(t.ownerNotMember, { ownerUserId: t.ownerNotMember }); + } + + let paymentAccountId: Types.ObjectId | null = null; + if (input.type === "credit_card" && input.cardPaymentAccountId) { + paymentAccountId = toObjectId(input.cardPaymentAccountId); + const payer = + paymentAccountId && + !paymentAccountId.equals(selfId ?? new Types.ObjectId()) && + (await AccountModel.exists({ + _id: paymentAccountId, + householdId, + archivedAt: null, + type: { $in: ["bank", "cash", "savings"] }, + })); + if (!payer) + return fail(t.invalidPaymentAccount, { cardPaymentAccountId: t.invalidPaymentAccount }); + } + + const isCard = input.type === "credit_card"; + return ok({ + name: input.name, + institution: input.institution, + mealCardProvider: input.type === "meal_card" ? input.mealCardProvider : null, + openingBalanceMinor: isCard ? -Math.abs(input.openingBalance) : input.openingBalance, + openingDate, + color: input.color, + icon: input.icon, + ownerUserId, + isPrivate: input.isPrivate, + overdraft: + input.type === "bank" && input.overdraftEnabled + ? { + enabled: true, + limitMinor: input.overdraftLimit, + monthlyRateBp: input.overdraftMonthlyRate, + statementDay: input.overdraftStatementDay, + } + : null, + creditCard: isCard + ? { + limitMinor: input.cardLimit, + statementDay: input.cardStatementDay, + dueRule: { kind: input.cardDueKind, value: input.cardDueValue }, + paymentAccountId, + } + : null, + }); +} + +export async function createAccount( + actorId: string, + householdId: string, + input: AccountForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const fields = await toDocFields(m.householdId, input); + if (!fields.ok) return fields; + const last = await AccountModel.findOne({ householdId: m.householdId }) + .sort({ order: -1 }) + .select({ order: 1 }) + .lean(); + const actor = new Types.ObjectId(actorId); + const doc = await AccountModel.create({ + ...fields.data, + householdId: m.householdId, + type: input.type, + order: (last?.order ?? -1) + 1, + createdBy: actor, + updatedBy: actor, + }); + await writeAudit({ + action: "account.create", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: String(doc._id), + diff: { name: input.name, type: input.type }, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateAccount( + actorId: string, + householdId: string, + accountId: string, + input: AccountForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(accountId); + const current = id + ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + // Tür değişikliği desteklenmez (işlemler ve kart dönemleri türe bağlıdır). + if (current.type !== input.type) return fail(t.typeLocked, { type: t.typeLocked }); + const fields = await toDocFields(m.householdId, input, current._id); + if (!fields.ok) return fields; + // Bakiyesini göremediği gizli hesapta açılış bakiyesi korunur (ne görünür ne değişir). + if (!canSeeBalance(current, actorId)) delete fields.data.openingBalanceMinor; + + const actor = new Types.ObjectId(actorId); + await AccountModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { ...fields.data, updatedBy: actor } }, + ); + await writeAudit({ + action: "account.update", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: accountId, + // Gizli hesabın bakiyesi denetim kaydına yazılmaz. + diff: diffFields( + { + name: current.name, + institution: current.institution, + isPrivate: current.isPrivate, + ownerUserId: String(current.ownerUserId ?? ""), + }, + { + name: input.name, + institution: input.institution, + isPrivate: input.isPrivate, + ownerUserId: input.ownerUserId ?? "", + }, + ), + }); + return ok(undefined); +} + +export async function setAccountArchived( + actorId: string, + householdId: string, + accountId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(accountId); + const current = id + ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await AccountModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "account.archive" : "account.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: accountId, + diff: { name: current.name }, + }); + return ok(undefined); +} + +export async function reorderAccount( + actorId: string, + householdId: string, + accountId: string, + direction: "up" | "down", +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const list = ( + await AccountModel.find({ householdId: m.householdId, archivedAt: null }).lean() + ).sort((a, b) => a.order - b.order || compareTr(a.name, b.name)); + const i = list.findIndex((a) => String(a._id) === accountId); + const j = direction === "up" ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= list.length) return ok(undefined); + [list[i], list[j]] = [list[j]!, list[i]!]; + await AccountModel.bulkWrite( + list.map((a, order) => ({ + updateOne: { + filter: { _id: a._id, householdId: m.householdId }, + update: { $set: { order } }, + }, + })), + ); + return ok(undefined); +} + +/** Hane üyeleri (hesap sahibi seçimi için). */ +export async function listMemberOptions(userId: string, householdId: string) { + const detail = await getHouseholdDetail(userId, householdId); + if (!detail) return null; + return detail.members.map((x) => ({ id: x.userId, name: x.name })); +} diff --git a/src/models/account.ts b/src/models/account.ts new file mode 100644 index 0000000..cd54770 --- /dev/null +++ b/src/models/account.ts @@ -0,0 +1,67 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ACCOUNT_TYPES, DUE_RULE_KINDS, MEAL_CARD_PROVIDERS } from "@/shared/accounts"; +import { ICON_KEYS, SWATCHES } from "@/shared/appearance"; + +/** + * Hesap (Bölüm 6.1). Tutarlar kuruş. Bakiye işareti: varlık pozitif, kart borcu negatif. + * Güncel bakiyenin tek doğruluk kaynağı işlemlerdir (Faz 6b); `balanceCacheMinor` yalnızca önbellek. + */ +const overdraftSchema = new Schema( + { + enabled: { type: Boolean, default: false }, + limitMinor: { type: Number, min: 0, default: 0 }, + /** Aylık faiz oranı, baz puan (4,25% → 425). */ + monthlyRateBp: { type: Number, min: 0, max: 10_000, default: null }, + statementDay: { type: Number, min: 1, max: 31, default: null }, + }, + { _id: false }, +); + +const creditCardSchema = new Schema( + { + limitMinor: { type: Number, min: 0, default: 0 }, + /** Hesap kesim günü; ayda yoksa (31) ayın son günü kullanılır (Faz 7). */ + statementDay: { type: Number, min: 1, max: 31, default: 1 }, + dueRule: { + kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" }, + value: { type: Number, min: 1, max: 31, default: 10 }, + }, + /** Kart borcunun ödeneceği varsayılan hesap. */ + paymentAccountId: { type: Schema.Types.ObjectId, default: null }, + }, + { _id: false }, +); + +const accountSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + type: { type: String, enum: ACCOUNT_TYPES, required: true }, + institution: { type: String, trim: true, maxlength: 80, default: "" }, + mealCardProvider: { type: String, enum: [...MEAL_CARD_PROVIDERS, null], default: null }, + /** İleride döviz hesabı için; şimdilik yalnızca TRY. */ + currency: { type: String, enum: ["TRY"], default: "TRY" }, + openingBalanceMinor: { type: Number, required: true, default: 0 }, + openingDate: { type: Date, required: true }, + color: { type: String, enum: SWATCHES, default: "blue" }, + icon: { type: String, enum: ICON_KEYS, default: "wallet" }, + order: { type: Number, default: 0 }, + archivedAt: { type: Date, default: null }, + /** Hesabın sahibi kişi (hane üyesi). "Yalnızca ben" hesaplarında bakiyeyi yalnızca o görür. */ + ownerUserId: { type: Schema.Types.ObjectId, default: null }, + isPrivate: { type: Boolean, default: false }, + overdraft: { type: overdraftSchema, default: null }, + creditCard: { type: creditCardSchema, default: null }, + balanceCacheMinor: { type: Number, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "accounts" }, +); + +accountSchema.index({ householdId: 1, archivedAt: 1, order: 1 }); + +export type Account = InferSchemaType; + +export const AccountModel: Model = + (models.Account as Model | undefined) ?? model("Account", accountSchema); diff --git a/src/shared/accounts.ts b/src/shared/accounts.ts new file mode 100644 index 0000000..957b774 --- /dev/null +++ b/src/shared/accounts.ts @@ -0,0 +1,20 @@ +/** Hesap türleri ve yemek kartı sağlayıcıları (Bölüm 6.1). */ +export const ACCOUNT_TYPES = ["cash", "bank", "savings", "credit_card", "meal_card"] as const; +export type AccountType = (typeof ACCOUNT_TYPES)[number]; + +export const MEAL_CARD_PROVIDERS = [ + "pluxee", + "multinet", + "edenred", + "setcard", + "metropol", + "other", +] as const; +export type MealCardProvider = (typeof MEAL_CARD_PROVIDERS)[number]; + +/** Kredi kartı son ödeme kuralı: kesimden N gün sonra | ayın sabit günü. */ +export const DUE_RULE_KINDS = ["daysAfter", "fixedDay"] as const; +export type DueRuleKind = (typeof DUE_RULE_KINDS)[number]; + +/** Borç (negatif bakiye) taşıyan hesap türleri. */ +export const isLiabilityType = (type: AccountType) => type === "credit_card"; diff --git a/src/shared/schemas/account.ts b/src/shared/schemas/account.ts new file mode 100644 index 0000000..9dc52b3 --- /dev/null +++ b/src/shared/schemas/account.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { ACCOUNT_TYPES, DUE_RULE_KINDS, MEAL_CARD_PROVIDERS } from "../accounts"; +import { ICON_KEYS, SWATCHES } from "../appearance"; +import { parseMoney } from "../money"; + +const v = tr.validation; + +/** Türkçe para girişi ("1.250,50") → kuruş. Boş → 0 (opsiyonel alanlarda). */ +export const moneyInputSchema = z + .string() + .trim() + .transform((s, ctx) => { + if (s === "") return 0; + const minor = parseMoney(s); + if (minor === null) { + ctx.addIssue({ code: "custom", message: v.money }); + return z.NEVER; + } + return minor; + }); + +/** Pozitif (≥ 0) para girişi. */ +export const nonNegativeMoneySchema = moneyInputSchema.refine((n) => n >= 0, v.moneyNonNegative); + +const day = (max: number) => z.coerce.number().int().min(1).max(max); +const objectIdOrEmpty = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((s) => (s === "" ? null : s)); + +/** Yüzde girişi ("4,25") → baz puan (425). Boş → null. */ +const percentToBp = z + .string() + .trim() + .transform((s, ctx) => { + if (s === "") return null; + const minor = parseMoney(s); // aynı ondalık kuralları: 2 hane + if (minor === null || minor < 0 || minor > 100_00) { + ctx.addIssue({ code: "custom", message: v.percent }); + return z.NEVER; + } + return minor; + }); + +export const accountFormSchema = z + .object({ + name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax), + type: z.enum(ACCOUNT_TYPES), + institution: z.string().trim().max(80, v.nameMax), + mealCardProvider: z.enum(MEAL_CARD_PROVIDERS).nullable(), + /** Kart için "açılış borcu" pozitif girilir; sunucu negatife çevirir. */ + openingBalance: moneyInputSchema, + /** yyyy-MM-dd (İstanbul günü) */ + openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date), + color: z.enum(SWATCHES), + icon: z.enum(ICON_KEYS), + ownerUserId: objectIdOrEmpty, + isPrivate: z.boolean(), + overdraftEnabled: z.boolean(), + overdraftLimit: nonNegativeMoneySchema, + overdraftMonthlyRate: percentToBp, + overdraftStatementDay: z + .union([z.literal(""), day(31)]) + .transform((d) => (d === "" ? null : d)), + cardLimit: nonNegativeMoneySchema, + cardStatementDay: day(31), + cardDueKind: z.enum(DUE_RULE_KINDS), + cardDueValue: day(31), + cardPaymentAccountId: objectIdOrEmpty, + }) + .superRefine((a, ctx) => { + if (a.type === "meal_card" && !a.mealCardProvider) { + ctx.addIssue({ code: "custom", path: ["mealCardProvider"], message: v.selectProvider }); + } + if (a.isPrivate && !a.ownerUserId) { + ctx.addIssue({ code: "custom", path: ["ownerUserId"], message: v.privateNeedsOwner }); + } + if (a.type === "credit_card" && a.cardDueKind === "daysAfter" && a.cardDueValue > 30) { + ctx.addIssue({ code: "custom", path: ["cardDueValue"], message: v.dueDays }); + } + // Yalnızca banka hesabı (KMH ile) negatif açılabilir; kart borcu pozitif girilir. + if (a.openingBalance < 0 && a.type !== "bank") { + ctx.addIssue({ code: "custom", path: ["openingBalance"], message: v.moneyNonNegative }); + } + }); + +export type AccountFormInput = z.input; +export type AccountForm = z.output;