Files
butcem/src/features/in-kind/service.ts
T

618 lines
20 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 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<ActionResult> {
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<string, { contributed: number }>();
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<InKindView[]> {
const today = todayIstanbul();
const flows = await savingsFlows(docs.map((d) => d._id));
const priceCache = new Map<string, PriceQuote | null>();
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<InKindView[] | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const filter: Record<string, unknown> = { 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<DebtDoc[]>();
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<DebtDoc>();
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult<{ transactionId: string }>> {
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<ActionResult<{ closed: boolean }>> {
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<Map<string, { direction: InKindDirection; label: string }[]>> {
const views = (await listInKind(userId, householdId)) ?? [];
const map = new Map<string, { direction: InKindDirection; label: string }[]>();
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;
}