feat(transactions): ek bağlama, hesap ekstresi ve mutabakat servisi, 004 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 2e1f4d7929
commit 2e2e33233f
15 changed files with 1049 additions and 22 deletions
+121 -20
View File
@@ -1,5 +1,10 @@
import "server-only";
import { Types, type PipelineStage } from "mongoose";
import mongoose, { Types, type ClientSession, type PipelineStage } from "mongoose";
import {
listOwnerAttachments,
syncOwnerAttachments,
type AttachmentInfo,
} from "@/features/attachments/service";
import { resolveMembership } from "@/features/households/service";
import { buildTree, toOptions, type CategoryOption } from "@/features/categories/tree";
import { tr } from "@/i18n/tr";
@@ -12,6 +17,7 @@ import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import { allowedAttachmentKinds } from "@/shared/attachments";
import type { IconKey, Swatch } from "@/shared/appearance";
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates";
import type { CategoryKind } from "@/shared/schemas/category";
@@ -52,7 +58,12 @@ type CategoryInfo = {
ancestors: string[];
};
async function loadContext(userId: string, householdId: string, minRole: "viewer" | "member") {
/** Hane bağlamı (hesap/kategori haritaları, gizli hesaplar). İşlemler modülü içinde paylaşılır. */
export async function loadContext(
userId: string,
householdId: string,
minRole: "viewer" | "member",
) {
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
const [accounts, categories] = await Promise.all([
@@ -116,7 +127,7 @@ async function loadContext(userId: string, householdId: string, minRole: "viewer
return { m, accountMap, categoryMap, hiddenAccountIds, flatCategories: flat };
}
type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara dokunan işlemleri dışlayan temel filtre. */
function baseFilter(ctx: Ctx): Record<string, unknown> {
@@ -316,15 +327,61 @@ export async function createTransaction(
d.categoryId,
);
const actor = new Types.ObjectId(actorId);
const doc = await TransactionModel.create({
...built.data,
try {
let id = "";
await mongoose.connection.transaction(async (session) => {
const [doc] = await TransactionModel.create(
[
{
...built.data,
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
id = String(doc!._id);
await attach(ctx, actorId, doc!._id, input, session);
});
return ok({ id });
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
}
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
class AttachmentError extends Error {
constructor(readonly result: ActionResult<never>) {
super(result.ok ? "" : result.error);
}
}
async function attach(
ctx: Ctx,
actorId: string,
transactionId: Types.ObjectId,
input: TransactionForm,
session: ClientSession,
) {
const synced = await syncOwnerAttachments({
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
actorId,
ownerType: "transaction",
ownerId: transactionId,
ids: input.attachmentIds,
allowedKinds: allowedAttachmentKinds(input.type),
session,
});
return ok({ id: String(doc._id) });
if (!synced.ok) throw new AttachmentError(synced);
await TransactionModel.updateOne(
{ _id: transactionId, householdId: ctx.m.householdId },
{ $set: { attachmentCount: synced.data } },
{ session },
);
}
async function loadOwnTransaction(
@@ -349,9 +406,17 @@ export async function updateTransaction(
if (!ctx) return fail(tr.common.unauthorized);
const current = await loadOwnTransaction(ctx, transactionId);
if (!current) return fail(tr.common.notFound);
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
const d = built.data as {
type: TransactionType;
date: Date;
amountMinor: number;
accountId: Types.ObjectId;
toAccountId: Types.ObjectId | null;
categoryId: Types.ObjectId | null;
};
const payeeId = await touchPayee(
ctx.m.householdId,
input.payee,
@@ -359,10 +424,33 @@ export async function updateTransaction(
d.accountId,
d.categoryId,
);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{ $set: { ...built.data, payeeId, updatedBy: new Types.ObjectId(actorId) } },
);
// Bakiyeyi etkileyen bir alan değiştiyse mutabakat işareti geçersizleşir.
const balanceChanged =
d.type !== current.type ||
d.amountMinor !== current.amountMinor ||
d.date.getTime() !== new Date(current.date).getTime() ||
!d.accountId.equals(current.accountId) ||
String(d.toAccountId ?? "") !== String(current.toAccountId ?? "");
try {
await mongoose.connection.transaction(async (session) => {
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
{
$set: {
...built.data,
payeeId,
updatedBy: new Types.ObjectId(actorId),
...(balanceChanged ? { reconciledAccountIds: [] } : {}),
},
},
{ session },
);
await attach(ctx, actorId, current._id, input, session);
});
} catch (error) {
if (error instanceof AttachmentError) return error.result;
throw error;
}
return ok(undefined);
}
@@ -413,6 +501,10 @@ export type TransactionListItem = {
tags: string[];
createdBy: { id: string; name: string };
attachmentCount: number;
/** Mutabakat düzeltmesi (kategorisiz, düzenlenemez). */
isAdjustment: boolean;
/** Mutabakatı yapılmış hesaplar. */
reconciledAccountIds: string[];
};
export type TransactionDetail = TransactionListItem & {
@@ -429,9 +521,10 @@ export type TransactionDetail = TransactionListItem & {
updatedAt: string;
/** Düzenleme formu için ham kimlikler. */
categoryId: string | null;
attachments: AttachmentInfo[];
};
async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
export async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
const unique = [...new Set(ids.map(String))].map((id) => new Types.ObjectId(id));
const users = await getDb()
.collection("user")
@@ -440,13 +533,13 @@ async function userNames(ids: Types.ObjectId[]): Promise<Map<string, string>> {
return new Map(users.map((u) => [String(u._id), String(u.name ?? "—")]));
}
type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
export type LeanTx = Awaited<ReturnType<typeof TransactionModel.find>>[number] & {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
};
function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionListItem {
const acc = (id: Types.ObjectId | null | undefined) => {
if (!id) return null;
const a = ctx.accountMap.get(String(id));
@@ -470,6 +563,8 @@ function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): TransactionLi
tags: tx.tags ?? [],
createdBy: { id: String(tx.createdBy), name: names.get(String(tx.createdBy)) ?? "—" },
attachmentCount: tx.attachmentCount ?? 0,
isAdjustment: Boolean(tx.isAdjustment),
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
};
}
@@ -571,8 +666,14 @@ export async function getTransaction(
if (!ctx) return null;
const tx = (await loadOwnTransaction(ctx, transactionId)) as unknown as LeanTx | null;
if (!tx) return null;
const names = await userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]);
const [names, attachments] = await Promise.all([
userNames([tx.createdBy as Types.ObjectId, tx.updatedBy as Types.ObjectId]),
tx.attachmentCount
? listOwnerAttachments(ctx.m.householdId, "transaction", tx._id)
: Promise.resolve([]),
]);
return {
attachments,
...toItem(ctx, tx, names),
categoryId: tx.categoryId ? String(tx.categoryId) : null,
splits: (tx.splits ?? []).map((s) => {
@@ -650,7 +751,7 @@ export async function exportTransactions(
} else {
out.push({
...common,
category: categoryPath(r.categoryId),
category: r.isAdjustment ? t.adjustment : categoryPath(r.categoryId),
note: "",
amountMinor: sign(r.type) * r.amountMinor,
});