cc624af69f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
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";
|
||
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;
|
||
minimumPaymentRateBp: number | 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;
|
||
|
||
/** 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, deltas: ReadonlyMap<string, number>): 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, deltas) : 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, deltas] = await Promise.all([
|
||
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
|
||
computeAccountDeltas(m.householdId),
|
||
]);
|
||
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, 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, deltas)), 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,
|
||
minimumPaymentRateBp: input.cardMinimumRate,
|
||
}
|
||
: 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 }));
|
||
}
|