feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent 380ddaf303
commit 8dd5fd42e3
9 changed files with 1805 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
import "server-only";
import { Types } from "mongoose";
import { getHouseholdDetail, resolveMembership } from "@/features/households/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;
} | 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;
/** FAZ 6b: açılış bakiyesi + işlemler. Şimdilik açılış bakiyesi. */
function computeBalance(a: Lean): number {
return a.openingBalanceMinor;
}
function toView(a: Lean, viewerId: string): 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) : 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 = await HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean();
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);
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 };
}
/** 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,
}
: 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 }));
}