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