feat(transactions): işlem servisi, bakiye hesaplama, satıcı kuralları ve izolasyon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 03:23:30 +03:00
parent d18c8a0d76
commit 1208e2d1eb
9 changed files with 1394 additions and 11 deletions
@@ -11,6 +11,7 @@ import {
Pencil,
Plus,
} from "lucide-react";
import Link from "next/link";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { IconBadge } from "@/components/appearance/swatch";
@@ -109,7 +110,12 @@ export function AccountsView({
<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>
<Link
href={`/islemler?hesap=${a.id}`}
className="block truncate font-medium hover:underline"
>
{a.name}
</Link>
<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>
+7 -2
View File
@@ -89,7 +89,9 @@ describe("“yalnızca ben” hesabı", () => {
(await listAccounts(ids.user(user), cakir))!.accounts.find(
(a) => a.name === "Ayşe — Pluxee",
)!;
expect((await find("ayse")).balanceMinor).toBe(3_120_00);
// Hesap sahibi açılış ve güncel bakiyeyi görür (güncel = açılış + işlemler).
expect((await find("ayse")).openingBalanceMinor).toBe(3_120_00);
expect((await find("ayse")).balanceMinor).not.toBeNull();
expect((await find("admin")).balanceMinor).toBeNull();
expect((await find("emre")).balanceMinor).toBeNull();
expect((await find("emre")).openingBalanceMinor).toBeNull();
@@ -104,7 +106,10 @@ describe("“yalnızca ben” hesabı", () => {
);
const without = (await listAccounts(ids.user("emre"), cakir))!.totals;
expect(without.excludesHidden).toBe(true);
expect(withHidden.assetsMinor - without.assetsMinor).toBe(3_120_00);
const pluxee = (await listAccounts(ids.user("ayse"), cakir))!.accounts.find(
(a) => a.name === "Ayşe — Pluxee",
)!;
expect(withHidden.assetsMinor - without.assetsMinor).toBe(pluxee.balanceMinor);
// 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);
+12 -8
View File
@@ -1,6 +1,7 @@
import "server-only";
import { Types } from "mongoose";
import { getHouseholdDetail, resolveMembership } from "@/features/households/service";
import { computeAccountDeltas } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { diffFields, writeAudit } from "@/lib/audit";
@@ -94,12 +95,12 @@ type Lean = {
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;
/** Güncel bakiye = açılış bakiyesi + gerçekleşmiş işlemlerin etkisi (tek doğruluk kaynağı: işlemler). */
function computeBalance(a: Lean, deltas: ReadonlyMap<string, number>): number {
return a.openingBalanceMinor + (deltas.get(String(a._id)) ?? 0);
}
function toView(a: Lean, viewerId: string): AccountView {
function toView(a: Lean, viewerId: string, deltas: ReadonlyMap<string, number>): AccountView {
const visible = canSeeBalance(a, viewerId);
return {
id: String(a._id),
@@ -113,7 +114,7 @@ function toView(a: Lean, viewerId: string): AccountView {
archived: Boolean(a.archivedAt),
isPrivate: a.isPrivate,
ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null,
balanceMinor: visible ? computeBalance(a) : null,
balanceMinor: visible ? computeBalance(a, deltas) : null,
openingBalanceMinor: visible ? a.openingBalanceMinor : null,
openingDate: toIstanbulYmd(a.openingDate),
overdraft: a.overdraft?.enabled ? a.overdraft : null,
@@ -140,7 +141,10 @@ export async function listAccounts(
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 [household, deltas] = await Promise.all([
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
computeAccountDeltas(m.householdId),
]);
const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true;
const totals: AccountTotals = {
@@ -158,12 +162,12 @@ export async function listAccounts(
continue;
}
if (!visible) totals.includesHidden = true;
const balance = computeBalance(a);
const balance = computeBalance(a, deltas);
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 };
return { accounts: rows.map((a) => toView(a, userId, deltas)), totals };
}
/** Form → belge alanları. Kart için açılış borcu negatif saklanır. */