Files
butcem/src/features/accounts/service.ts
T

383 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }));
}