feat(in-kind): varlık türleri, fiyat tablosu, aynî borç/alacak, birikim planı ve ödeme akışı, 008 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:37:48 +03:00
parent 1062b2d96c
commit 9781cd9ed2
15 changed files with 1354 additions and 3 deletions
+573
View File
@@ -0,0 +1,573 @@
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 {
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 type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
export async function priceOn(
householdId: Types.ObjectId,
assetKey: AssetKey,
date: string,
): Promise<PriceQuote | null> {
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
.sort({ date: -1 })
.lean();
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
}
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 } },
);
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,
);
await mongoose.connection.transaction(async (session) => {
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,
);
});
return ok({ closed });
}
/** 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;
}