9a60085d14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
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;
|
||
}
|