import "server-only"; import mongoose, { Types } from "mongoose"; import { hasHouseholdRole } from "@/features/households/roles"; import { loadContext, type Ctx } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { fail, ok, type ActionResult } from "@/lib/action-result"; import { writeAudit } from "@/lib/audit"; import { toObjectId } from "@/lib/object-id"; import { AssetPriceModel } from "@/models/asset-price"; import { InKindDebtModel } from "@/models/in-kind-debt"; import { TransactionModel } from "@/models/transaction"; import { addMovement } from "@/features/assets/service"; import { AssetMovementModel } from "@/models/asset-movement"; import { priceOn, type PriceQuote } from "./prices"; import { ASSET_KEYS, formatAssetQuantity, inKindProgress, quantityForAmount, valuationPrice, valueOf, type AssetKey, type InKindDirection, type InKindProgress, } from "@/shared/assets"; import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; import type { AssetPriceForm, ContributionForm, InKindForm, PaymentForm, } from "@/shared/schemas/in-kind"; import { foldTr } from "@/shared/search"; /** * Aynî borç/alacak ve fiyat tablosu (Bölüm 6.5–6.6). Okuma: viewer; yazma: member. * Görünürlük cariye bağlıdır ("yalnızca ben" cari → kayıt da gizli). */ const t = tr.inKind; // --- Fiyat tablosu --- export { priceOn, type PriceQuote } from "./prices"; export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] }; export async function listPrices( userId: string, householdId: string, ): Promise<{ rows: PriceRow[]; canWrite: boolean } | null> { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const all = await AssetPriceModel.find({ householdId: ctx.m.householdId }) .sort({ date: -1 }) .limit(1000) .lean(); const rows = ASSET_KEYS.map((assetKey): PriceRow => { const history = all .filter((p) => p.assetKey === assetKey) .slice(0, 10) .map((p) => ({ buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date })); return { assetKey, latest: history[0] ?? null, history }; }); return { rows, canWrite: hasHouseholdRole(ctx.m.role, "member") }; } export async function savePrice( actorId: string, householdId: string, input: AssetPriceForm, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date }); await AssetPriceModel.updateOne( { householdId: ctx.m.householdId, assetKey: input.assetKey, date: input.date }, { $set: { buyMinor: input.buyMinor, sellMinor: input.sellMinor, source: "manual" }, $setOnInsert: { createdBy: new Types.ObjectId(actorId) }, }, { upsert: true }, ); return ok(undefined); } // --- Aynî kayıtlar --- type PaymentDoc = { _id: Types.ObjectId; date: string; method: "asset" | "cash"; quantity: number; amountMinor: number; accountId?: Types.ObjectId | null; transactionId?: Types.ObjectId | null; note: string; }; type DebtDoc = { _id: Types.ObjectId; householdId: Types.ObjectId; contactId: Types.ObjectId; direction: InKindDirection; assetKey: AssetKey; quantity: number; date: string; unitPriceMinor?: number | null; valueMinor?: number | null; note: string; savingsAccountId?: Types.ObjectId | null; monthlyPlanMinor?: number | null; payments: PaymentDoc[]; closedAt?: string | null; }; export type InKindView = { id: string; contactId: string; contactName: string; direction: InKindDirection; assetKey: AssetKey; label: string; quantity: number; paidQuantity: number; date: string; unitPriceMinor: number | null; valueMinor: number | null; note: string; savingsAccount: { id: string; name: string } | null; monthlyPlanMinor: number | null; closedAt: string | null; currentPrice: PriceQuote | null; progress: InKindProgress; payments: { id: string; date: string; method: "asset" | "cash"; quantity: number; amountMinor: number; account: string | null; note: string; }[]; }; function contactVisible(ctx: Ctx, contactId: Types.ObjectId | string) { const c = ctx.contactMap.get(String(contactId)); return c && !c.hidden ? c : null; } /** Birikim: borca bağlı katkılar (birikim hesabına giren) − birikim hesabından yapılan ödemeler. */ async function savingsFlows(debtIds: Types.ObjectId[]) { const rows = await TransactionModel.aggregate<{ _id: { debt: Types.ObjectId; role: string }; sum: number; }>([ { $match: { inKindDebtId: { $in: debtIds }, status: "posted", deletedAt: null, }, }, { $group: { _id: { debt: "$inKindDebtId", role: "$inKindRole" }, sum: { $sum: "$amountMinor" }, }, }, ]); const map = new Map(); for (const r of rows) { if (r._id.role !== "contribution") continue; map.set(String(r._id.debt), { contributed: r.sum }); } return map; } async function toViews(ctx: Ctx, docs: DebtDoc[]): Promise { const today = todayIstanbul(); const flows = await savingsFlows(docs.map((d) => d._id)); const priceCache = new Map(); const views: InKindView[] = []; for (const d of docs) { if (!priceCache.has(d.assetKey)) priceCache.set(d.assetKey, await priceOn(ctx.m.householdId, d.assetKey, today)); const price = priceCache.get(d.assetKey)!; const paidQuantity = d.payments.reduce((a, p) => a + p.quantity, 0); const savingsId = d.savingsAccountId ? String(d.savingsAccountId) : null; // Birikim hesabından yapılan ödemeler birikimden düşer. const spentMinor = d.payments .filter((p) => savingsId && String(p.accountId) === savingsId) .reduce((a, p) => a + p.amountMinor, 0); const savings = savingsId ? ctx.accountMap.get(savingsId) : undefined; views.push({ id: String(d._id), contactId: String(d.contactId), contactName: ctx.contactMap.get(String(d.contactId))?.name ?? "—", direction: d.direction, assetKey: d.assetKey, label: formatAssetQuantity(d.assetKey, d.quantity), quantity: d.quantity, paidQuantity, date: d.date, unitPriceMinor: d.unitPriceMinor ?? null, valueMinor: d.valueMinor ?? null, note: d.note, savingsAccount: savings && !savings.hidden ? { id: savingsId!, name: savings.name } : null, monthlyPlanMinor: d.monthlyPlanMinor ?? null, closedAt: d.closedAt ?? null, currentPrice: price, progress: inKindProgress({ quantity: d.quantity, paidQuantity, unitPriceMinor: valuationPrice(d.direction, price), contributedMinor: flows.get(String(d._id))?.contributed ?? 0, spentMinor, monthlyPlanMinor: d.direction === "debt" ? (d.monthlyPlanMinor ?? null) : null, today, }), payments: d.payments.map((p) => { const a = p.accountId ? ctx.accountMap.get(String(p.accountId)) : undefined; return { id: String(p._id), date: p.date, method: p.method, quantity: p.quantity, amountMinor: p.amountMinor, account: a ? (a.hidden ? tr.transactions.list.hiddenAccount : a.name) : null, note: p.note, }; }), }); } return views; } /** Bir carinin (veya tüm görünür carilerin) aynî kayıtları; açıklar önce. */ export async function listInKind( userId: string, householdId: string, contactId?: string, ): Promise { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; const filter: Record = { householdId: ctx.m.householdId, deletedAt: null }; if (contactId) { const id = toObjectId(contactId); if (!id || !contactVisible(ctx, id)) return null; filter.contactId = id; } else { filter.contactId = { $nin: ctx.hiddenContactIds }; } const docs = await InKindDebtModel.find(filter).sort({ closedAt: 1, date: -1 }).lean(); return toViews( ctx, docs.filter((d) => contactVisible(ctx, d.contactId)), ); } async function loadDebt(ctx: Ctx, debtId: string) { const id = toObjectId(debtId); if (!id) return null; const d = await InKindDebtModel.findOne({ _id: id, householdId: ctx.m.householdId, deletedAt: null, }).lean(); return d && contactVisible(ctx, d.contactId) ? d : null; } function checkAccount(ctx: Ctx, id: string | null) { if (!id) return null; const a = ctx.accountMap.get(id); return a && !a.hidden && !a.archived ? a : null; } async function debtFields(ctx: Ctx, input: InKindForm) { let unitPriceMinor = input.unitPrice; if (unitPriceMinor === null) { const p = await priceOn(ctx.m.householdId, input.assetKey, input.date); unitPriceMinor = valuationPrice(input.direction, p); } if (unitPriceMinor === null) return fail(t.errors.noPrice, { unitPrice: t.errors.noPrice }); if (input.savingsAccountId && input.direction !== "debt") return fail(t.errors.receivablePlan, { savingsAccountId: t.errors.receivablePlan }); const savings = input.savingsAccountId ? checkAccount(ctx, input.savingsAccountId) : null; if (input.savingsAccountId && !savings) return fail(tr.transactions.validation.accountInvalid, { savingsAccountId: tr.transactions.validation.accountInvalid, }); return ok({ direction: input.direction, assetKey: input.assetKey, quantity: input.quantity, date: input.date, unitPriceMinor, valueMinor: valueOf(input.quantity, unitPriceMinor), note: input.note, savingsAccountId: savings?.id ?? null, monthlyPlanMinor: savings ? input.monthlyPlan : null, }); } export async function createInKind( actorId: string, householdId: string, contactId: string, input: InKindForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const cid = toObjectId(contactId); const contact = cid ? contactVisible(ctx, cid) : null; if (!contact) return fail(tr.common.notFound); if (contact.archived) return fail(tr.contacts.errors.archived); const fields = await debtFields(ctx, input); if (!fields.ok) return fields; const actor = new Types.ObjectId(actorId); const doc = await InKindDebtModel.create({ ...fields.data, householdId: ctx.m.householdId, contactId: contact.id, createdBy: actor, updatedBy: actor, }); return ok({ id: String(doc._id) }); } export async function updateInKind( actorId: string, householdId: string, debtId: string, input: InKindForm, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const d = await loadDebt(ctx, debtId); if (!d) return fail(tr.common.notFound); const paid = d.payments.reduce((a, p) => a + p.quantity, 0); if (input.quantity < paid) return fail(t.errors.quantityBelowPaid, { quantity: t.errors.quantityBelowPaid }); // Ödeme varsa yön ve varlık değişemez (ödemeler o varlık cinsindendir). if (d.payments.length && (input.direction !== d.direction || input.assetKey !== d.assetKey)) return fail(t.errors.closed); const fields = await debtFields(ctx, input); if (!fields.ok) return fields; await InKindDebtModel.updateOne( { _id: d._id, householdId: ctx.m.householdId }, { $set: { ...fields.data, closedAt: paid >= input.quantity ? (d.closedAt ?? todayIstanbul()) : null, updatedBy: new Types.ObjectId(actorId), }, }, ); return ok(undefined); } export async function deleteInKind( actorId: string, householdId: string, debtId: string, ): Promise { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const d = await loadDebt(ctx, debtId); if (!d) return fail(tr.common.notFound); const actor = new Types.ObjectId(actorId); await InKindDebtModel.updateOne( { _id: d._id }, { $set: { deletedAt: new Date(), updatedBy: actor } }, ); // Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir). await AssetMovementModel.updateMany( { householdId: ctx.m.householdId, inKindDebtId: d._id }, { $set: { inKindDebtId: null } }, ); await writeAudit({ action: "inkind.delete", actorId: actor, householdId: ctx.m.householdId, targetType: "inKindDebt", targetId: String(d._id), diff: { assetKey: d.assetKey, quantity: d.quantity, direction: d.direction }, }); return ok(undefined); } // --- Birikim ve ödeme --- /** Birikim katkısı: kaynak hesaptan borcun birikim hesabına transfer (borç kapanmaz). */ export async function addContribution( actorId: string, householdId: string, debtId: string, input: ContributionForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const d = await loadDebt(ctx, debtId); if (!d) return fail(tr.common.notFound); if (d.closedAt) return fail(t.errors.closed); if (d.direction !== "debt" || !d.savingsAccountId) return fail(t.errors.noSavings); const from = checkAccount(ctx, input.fromAccountId); const to = checkAccount(ctx, String(d.savingsAccountId)); if (!from || !to) return fail(tr.transactions.validation.accountInvalid, { fromAccountId: tr.transactions.validation.accountInvalid, }); if (from.id.equals(to.id)) return fail(t.errors.sameAccount, { fromAccountId: t.errors.sameAccount }); const date = istanbulDayStart(input.date); if (!date) return fail(tr.validation.date, { date: tr.validation.date }); const actor = new Types.ObjectId(actorId); const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? ""; const description = t.contributionDescription( formatAssetQuantity(d.assetKey, d.quantity), contactName, ); const tx = await TransactionModel.create({ householdId: ctx.m.householdId, type: "transfer", status: "posted", date, accountId: from.id, toAccountId: to.id, amountMinor: input.amount, description, searchText: foldTr(description), contactId: d.contactId, inKindDebtId: d._id, inKindRole: "contribution", createdBy: actor, updatedBy: actor, }); return ok({ transactionId: String(tx._id) }); } /** * "Ödeme gerçekleşti": kısmi ödeme miktarı düşürür, tam ödemede kayıt kapanır. TL tutar varsa * hesaptan çıkış (borç) veya hesaba giriş (alacak) işlemi aynı transaction içinde oluşturulur. */ export async function recordPayment( actorId: string, householdId: string, debtId: string, input: PaymentForm, ): Promise> { const ctx = await loadContext(actorId, householdId, "member"); if (!ctx) return fail(tr.common.unauthorized); const d = await loadDebt(ctx, debtId); if (!d) return fail(tr.common.notFound); if (d.closedAt) return fail(t.errors.closed); if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date }); const date = istanbulDayStart(input.date); if (!date) return fail(tr.validation.date, { date: tr.validation.date }); const account = input.accountId ? checkAccount(ctx, input.accountId) : null; if (input.accountId && !account) return fail(tr.transactions.validation.accountInvalid, { accountId: tr.transactions.validation.accountInvalid, }); let quantity = input.quantity; if (quantity === null) { const price = valuationPrice( d.direction, await priceOn(ctx.m.householdId, d.assetKey, input.date), ); if (!price) return fail(t.errors.noCurrentPrice, { quantity: t.errors.noCurrentPrice }); quantity = quantityForAmount(input.amount ?? 0, price); } const paid = d.payments.reduce((a, p) => a + p.quantity, 0); const remaining = d.quantity - paid; quantity = Math.min(quantity, remaining); if (quantity <= 0) return fail(t.validation.quantity, { quantity: t.validation.quantity }); const closed = paid + quantity >= d.quantity; const actor = new Types.ObjectId(actorId); const amountMinor = input.amount ?? 0; const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? ""; const description = t.paymentDescription( d.direction === "debt", formatAssetQuantity(d.assetKey, quantity), contactName, ); if (input.assetId) { const asset = ctx.assetMap.get(input.assetId); if (!asset || asset.hidden || asset.deleted) return fail(tr.common.notFound, { assetId: tr.common.notFound }); if (asset.assetKey !== d.assetKey) return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch }); } const writePayment = async (session: mongoose.ClientSession) => { let transactionId: Types.ObjectId | null = null; if (amountMinor > 0 && account) { const out = d.direction === "debt"; const [tx] = await TransactionModel.create( [ { householdId: ctx.m.householdId, type: "transfer", status: "posted", date, // Borç ödemesi hesaptan çıkar; alacak tahsilatı hesaba girer. accountId: out ? account.id : null, toAccountId: out ? null : account.id, amountMinor, description, searchText: foldTr(description), contactId: d.contactId, inKindDebtId: d._id, inKindRole: "payment", createdBy: actor, updatedBy: actor, }, ], { session }, ); transactionId = tx!._id; } await InKindDebtModel.updateOne( { _id: d._id, householdId: ctx.m.householdId, closedAt: null }, { $push: { payments: { date: input.date, method: input.method, quantity, amountMinor, accountId: account?.id ?? null, transactionId, note: input.note, createdBy: actor, }, }, $set: { closedAt: closed ? input.date : null, updatedBy: actor }, }, { session }, ); await writeAudit( { action: closed ? "inkind.close" : "inkind.payment", actorId: actor, householdId: ctx.m.householdId, targetType: "inKindDebt", targetId: String(d._id), diff: { method: input.method, quantity, amountMinor, remaining: remaining - quantity }, }, session, ); }; try { await mongoose.connection.transaction(async (session) => { // Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş. if (input.assetId) { const r = await addMovement( actorId, householdId, input.assetId, { kind: d.direction === "debt" ? "gift_out" : "gift_in", date: input.date, quantity, unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null), amount: null, accountId: null, storage: "", note: t.link.movementNote(contactName), }, { inKindDebtId: d._id, session }, ); if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity); } await writePayment(session); }); } catch (error) { if (error instanceof LinkError) return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined); throw error; } return ok({ closed }); } /** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */ class LinkError extends Error { constructor( message: string, readonly quantity?: string, ) { super(message); } } /** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */ export async function openInKindByContact( userId: string, householdId: string, ): Promise> { const views = (await listInKind(userId, householdId)) ?? []; const map = new Map(); for (const v of views) { if (v.closedAt) continue; const list = map.get(v.contactId) ?? []; list.push({ direction: v.direction, label: formatAssetQuantity(v.assetKey, v.quantity - v.paidQuantity), }); map.set(v.contactId, list); } return map; }