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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user