feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {};
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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<AccountType, IconKey> = {
|
||||
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 (
|
||||
<Select value={value || NONE} onValueChange={(v) => onChange(v === NONE ? "" : v)}>
|
||||
<SelectTrigger id={id} className="w-28">
|
||||
<SelectValue>{value || "—"}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{allowEmpty ? <SelectItem value={NONE}>—</SelectItem> : null}
|
||||
{DAYS.slice(0, max).map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function MoneyField({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
allowNegative,
|
||||
disabled,
|
||||
}: {
|
||||
control: Control<FormValues, unknown, AccountForm>;
|
||||
name: "openingBalance" | "overdraftLimit" | "cardLimit";
|
||||
label: string;
|
||||
hint?: string;
|
||||
allowNegative?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={`acc-${name}`}>{label}</FieldLabel>
|
||||
<MoneyInput
|
||||
id={`acc-${name}`}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
allowNegative={allowNegative}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? t.hidden : "0,00"}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-describedby={hint ? `acc-${name}-hint` : undefined}
|
||||
/>
|
||||
{hint ? <FieldDescription id={`acc-${name}-hint`}>{hint}</FieldDescription> : null}
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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, unknown, AccountForm>({
|
||||
// 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 (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={isEdit ? t.editTitle : t.createTitle}
|
||||
description={t.formDescription}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => handleOpenChange(false)}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="account-form" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
|
||||
{isEdit ? tr.common.save : tr.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="account-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acc-type">{f.type}</FieldLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
disabled={isEdit}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v);
|
||||
if (!isEdit) form.setValue("icon", DEFAULT_ICON[v as AccountType]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="acc-type" className="w-full">
|
||||
<SelectValue>{t.types[field.value]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_TYPES.map((x) => (
|
||||
<SelectItem key={x} value={x}>
|
||||
{t.types[x]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit ? <FieldDescription>{f.typeLockedHint}</FieldDescription> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-name">{f.name}</FieldLabel>
|
||||
<Input {...field} id="acc-name" aria-invalid={fieldState.invalid} />
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{type === "meal_card" ? (
|
||||
<Controller
|
||||
name="mealCardProvider"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-provider">{f.provider}</FieldLabel>
|
||||
<Select value={field.value ?? undefined} onValueChange={field.onChange}>
|
||||
<SelectTrigger
|
||||
id="acc-provider"
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue placeholder={f.provider}>
|
||||
{field.value ? t.providers[field.value] : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEAL_CARD_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{t.providers[p]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
) : type !== "cash" ? (
|
||||
<Controller
|
||||
name="institution"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acc-institution">{f.institution}</FieldLabel>
|
||||
<Input {...field} id="acc-institution" placeholder={f.institutionPlaceholder} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<MoneyField
|
||||
control={form.control}
|
||||
name="openingBalance"
|
||||
label={isCard ? f.openingDebt : f.openingBalance}
|
||||
hint={
|
||||
balanceHidden
|
||||
? t.hiddenHint
|
||||
: isCard
|
||||
? f.openingDebtHint
|
||||
: type === "bank"
|
||||
? f.openingBalanceHint
|
||||
: undefined
|
||||
}
|
||||
allowNegative={type === "bank"}
|
||||
disabled={balanceHidden}
|
||||
/>
|
||||
<Controller
|
||||
name="openingDate"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-date">{f.openingDate}</FieldLabel>
|
||||
<Input {...field} id="acc-date" type="date" aria-invalid={fieldState.invalid} />
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCard ? (
|
||||
<FieldSet>
|
||||
<FieldLegend>{f.card}</FieldLegend>
|
||||
<FieldGroup>
|
||||
<MoneyField control={form.control} name="cardLimit" label={f.cardLimit} />
|
||||
<Controller
|
||||
name="cardStatementDay"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acc-card-statement">{f.cardStatementDay}</FieldLabel>
|
||||
<DaySelect
|
||||
id="acc-card-statement"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FieldDescription>{f.cardStatementDayHint}</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="cardDueKind"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acc-card-due-kind">{f.cardDueKind}</FieldLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="acc-card-due-kind" className="w-full">
|
||||
<SelectValue>{f.dueKinds[field.value]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DUE_RULE_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{f.dueKinds[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="cardDueValue"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-card-due-value">
|
||||
{dueKind === "daysAfter" ? f.cardDueValueDays : f.cardDueValueDay}
|
||||
</FieldLabel>
|
||||
<DaySelect
|
||||
id="acc-card-due-value"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
max={dueKind === "daysAfter" ? 30 : 31}
|
||||
/>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="cardPaymentAccountId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-card-payer">{f.cardPaymentAccount}</FieldLabel>
|
||||
<Select
|
||||
value={field.value || NONE}
|
||||
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="acc-card-payer"
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue>
|
||||
{paymentAccounts.find((p) => p.id === field.value)?.name ??
|
||||
f.cardPaymentAccountNone}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.cardPaymentAccountNone}</SelectItem>
|
||||
{paymentAccounts
|
||||
.filter((p) => p.id !== account?.id)
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" className="-ml-2 w-fit">
|
||||
<ChevronDown
|
||||
className={
|
||||
advancedOpen ? "rotate-180 transition-transform" : "transition-transform"
|
||||
}
|
||||
/>
|
||||
{f.advanced}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<FieldGroup>
|
||||
{type === "bank" ? (
|
||||
<FieldSet>
|
||||
<FieldLegend>{f.overdraft}</FieldLegend>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="overdraftEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="acc-od"
|
||||
checked={field.value}
|
||||
onCheckedChange={(v) => field.onChange(v === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="acc-od" className="font-normal">
|
||||
{f.overdraftEnabled}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{overdraftEnabled ? (
|
||||
<>
|
||||
<MoneyField
|
||||
control={form.control}
|
||||
name="overdraftLimit"
|
||||
label={f.overdraftLimit}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Controller
|
||||
name="overdraftMonthlyRate"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-od-rate">{f.overdraftRate}</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="acc-od-rate"
|
||||
inputMode="decimal"
|
||||
placeholder="4,25"
|
||||
className="w-28 tabular-nums"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid ? (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
) : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="overdraftStatementDay"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="acc-od-day">
|
||||
{f.overdraftStatementDay}
|
||||
</FieldLabel>
|
||||
<DaySelect
|
||||
id="acc-od-day"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allowEmpty
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
|
||||
<Controller
|
||||
name="ownerUserId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="acc-owner">{f.owner}</FieldLabel>
|
||||
<Select
|
||||
value={field.value || NONE}
|
||||
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="acc-owner"
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue>
|
||||
{members.find((m) => m.id === field.value)?.name ?? f.ownerNone}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.ownerNone}</SelectItem>
|
||||
{members.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isPrivate"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id="acc-private"
|
||||
checked={field.value}
|
||||
onCheckedChange={(v) => field.onChange(v === true)}
|
||||
/>
|
||||
<FieldLabel htmlFor="acc-private" className="font-normal">
|
||||
{f.isPrivate}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="color"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel id="acc-color-label">{tr.categories.color}</FieldLabel>
|
||||
<SwatchPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
labelledBy="acc-color-label"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="icon"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<FieldLabel id="acc-icon-label">{tr.categories.icon}</FieldLabel>
|
||||
<IconPicker
|
||||
value={field.value}
|
||||
color={color}
|
||||
onChange={field.onChange}
|
||||
labelledBy="acc-icon-label"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{form.formState.errors.root ? (
|
||||
<FieldError>{form.formState.errors.root.message}</FieldError>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
|
||||
title={t.hiddenHint}
|
||||
>
|
||||
<EyeOff className="size-3.5" />
|
||||
{t.hidden}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const liability = isLiabilityType(account.type);
|
||||
const value = account.balanceMinor;
|
||||
return (
|
||||
<span className="flex flex-col items-end">
|
||||
{liability ? <span className="text-xs text-muted-foreground">{t.debt}</span> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold tabular-nums",
|
||||
(liability ? value < 0 : value < 0) && "text-expense",
|
||||
)}
|
||||
>
|
||||
{formatMoney(liability ? Math.abs(value) : value)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<AccountView | null>(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 (
|
||||
<li className={cn("flex items-center gap-3 p-3", a.archived && "opacity-60")}>
|
||||
<IconBadge icon={a.icon} color={a.color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{a.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{a.type === "meal_card" && a.mealCardProvider ? (
|
||||
<span>{t.providers[a.mealCardProvider]}</span>
|
||||
) : null}
|
||||
{a.institution ? <span>{a.institution}</span> : null}
|
||||
{owner ? <span>· {owner}</span> : null}
|
||||
{a.isPrivate ? <Badge variant="outline">{t.privateBadge}</Badge> : null}
|
||||
{a.overdraft ? (
|
||||
<Badge variant="secondary">
|
||||
{t.overdraftBadge(formatMoney(a.overdraft.limitMinor))}
|
||||
</Badge>
|
||||
) : null}
|
||||
{a.creditCard ? (
|
||||
<>
|
||||
<Badge variant="secondary">
|
||||
{t.limitLabel(formatMoney(a.creditCard.limitMinor))}
|
||||
</Badge>
|
||||
<span>{t.statementLabel(a.creditCard.statementDay)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<BalanceText account={a} />
|
||||
{canEdit ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${a.name} — ${tr.common.actions}`}
|
||||
disabled={pending}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{a.archived ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
run(setAccountArchivedAction(a.id, false), t.unarchivedToast(a.name))
|
||||
}
|
||||
>
|
||||
<ArchiveRestore />
|
||||
{t.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => setDialog({ account: a })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={index === 0}
|
||||
onSelect={() => run(reorderAccountAction(a.id, "up"))}
|
||||
>
|
||||
<ArrowUp />
|
||||
{tr.categories.moveUp}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={index === active.length - 1}
|
||||
onSelect={() => run(reorderAccountAction(a.id, "down"))}
|
||||
>
|
||||
<ArrowDown />
|
||||
{tr.categories.moveDown}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => setArchiving(a)}>
|
||||
<Archive />
|
||||
{t.archive}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{summary.map((s) => (
|
||||
<Card key={s.label} size="sm">
|
||||
<CardHeader>
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className={cn("text-base tabular-nums sm:text-lg", s.tone)}>
|
||||
{formatMoney(s.value)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{totals.includesHidden || totals.excludesHidden ? (
|
||||
<p className="-mt-2 text-xs text-muted-foreground">
|
||||
{totals.excludesHidden ? t.summary.excludesHidden : t.summary.includesHidden}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.ownerOnly}</p> : null}
|
||||
{canEdit ? (
|
||||
<Button className="ml-auto" onClick={() => setDialog({})}>
|
||||
<Plus />
|
||||
{t.newAccount}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{active.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||||
<p>{t.empty}</p>
|
||||
{canEdit ? <p className="mt-1 text-sm">{t.emptyOwnerHint}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
ACCOUNT_TYPES.map((type) => {
|
||||
const group = active.filter((a) => a.type === type);
|
||||
if (!group.length) return null;
|
||||
return (
|
||||
<section
|
||||
key={type}
|
||||
aria-labelledby={`acc-group-${type}`}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<h2 id={`acc-group-${type}`} className="text-sm font-medium text-muted-foreground">
|
||||
{t.typeGroups[type]}
|
||||
</h2>
|
||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||
{group.map((a) => (
|
||||
<Row key={a.id} a={a} index={active.indexOf(a)} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{archived.length ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="-ml-2 w-fit">
|
||||
<ChevronDown />
|
||||
{t.archivedSection(archived.length)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-2 divide-y rounded-xl border bg-card">
|
||||
{archived.map((a) => (
|
||||
<Row key={a.id} a={a} index={-1} />
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
|
||||
<AccountFormDialog
|
||||
open={Boolean(dialog)}
|
||||
account={dialog?.account}
|
||||
members={members}
|
||||
paymentAccounts={paymentAccounts}
|
||||
onOpenChange={(o) => !o && setDialog(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={Boolean(archiving)} onOpenChange={(o) => !o && setArchiving(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{archiving ? t.archiveDescription(archiving.name) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const a = archiving!;
|
||||
setArchiving(null);
|
||||
run(setAccountArchivedAction(a.id, true), t.archivedToast(a.name));
|
||||
}}
|
||||
>
|
||||
{t.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof setupSeededDb>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<NonNullable<AccountView["creditCard"]>, "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<string, unknown> = { householdId: m.householdId };
|
||||
if (!opts.includeArchived) filter.archivedAt = null;
|
||||
const rows = (await AccountModel.find(filter).lean<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<ActionResult<Record<string, unknown>>> {
|
||||
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<ActionResult<{ id: string }>> {
|
||||
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<ActionResult> {
|
||||
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<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<ActionResult> {
|
||||
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<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<ActionResult> {
|
||||
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<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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user