feat: Faz 8b - aynî borç/alacak ve fiyat tablosu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -6,7 +6,22 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
Faz 8 (alt fazlar halinde; `v0.8.0` Faz 8b sonunda).
|
||||
## [0.8.0] - 2026-11-05
|
||||
|
||||
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
|
||||
|
||||
### Eklendi — Faz 8b: aynî borç/alacak ve fiyat tablosu
|
||||
|
||||
- Varlık türleri: gram altın, çeyrek, yarım, tam, Cumhuriyet altını, 22 ayar bilezik, gümüş, USD, EUR, GBP (birim: gr / adet / birim; miktarlar binde bir hassasiyetle).
|
||||
- Fiyat tablosu (`/fiyatlar`, `assetPrices`): varlık × tarih × alış/satış, elle giriş; tek fiyat girilirse ikisine de yazılır; gün başına tek kayıt; son fiyatlar ve geçmiş. Faz 9 varlık takibi aynı tabloyu kullanacak.
|
||||
- Aynî borç/alacak (`inKindDebts`), cari sayfasında: cari, yön (ben borçluyum / bana borçlu), varlık, miktar, borç tarihi; o tarihteki birim fiyat ve TL karşılığı not olarak saklanır (boşsa fiyat tablosundan). Değerleme: borç satış, alacak alış fiyatıyla.
|
||||
- Borca bağlı birikim planı: birikim hesabı ve aylık tutar; "Birikim ekle" kaynak hesaptan birikim hesabına transfer oluşturur, borç kapanmaz. Kartta güncel değer, biriken, kalan ihtiyaç ve tahmini kapanış ayı.
|
||||
- "Ödeme gerçekleşti": aynı varlıkla veya TL karşılığıyla; tarih, miktar/tutar, hesap. TL ödemede miktar boşsa güncel fiyattan hesaplanır; kısmi ödeme miktarı düşürür, kalanı aşan ödeme kalana sınırlanır, tam ödemede kayıt kapanır. Borç ödemesi hesaptan çıkış, alacak tahsilatı hesaba giriş işlemi olarak aynı transaction'da oluşturulur; denetim kaydı (`inkind.payment`, `inkind.close`).
|
||||
- Cari listesinde açık aynî kayıtlar rozet olarak.
|
||||
- Aynî borç hareketleri cari TL bakiyesine ve gelir/gider toplamlarına girmez; "yalnızca ben" carinin aynî kayıtları ve hareketleri başkasına görünmez (hesap ekstresinde ad ve açıklama gizlenir). Ödeme işlemi tek başına silinemez.
|
||||
- Seed: son 6 ayın örnek altın/döviz fiyatları (gerçek piyasa verisi değildir), birikim planlı 5 gr altın borcu (iki katkıyla), USD alacağı, Annemler'de çeyrek altın alacağı.
|
||||
- `008` migration'ı: `assetPrices`, `inKindDebts` index'leri, işlemlerde aynî borç alanları.
|
||||
- Testler: değer/miktar karşılığı, değerleme fiyatı, ilerleme ve tahmini kapanış (birim); fiyat tablosu, tarih fiyatı, birikim katkısı, kısmi/tam ödeme, kapanış, alacak tahsilatı, cari bakiye ve gelir/gider ayrımı, gizli cari ve yetki (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 8a: cari hesaplar
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^7.0.2",
|
||||
"@tanstack/react-table": "^9.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "butcem",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "008-in-kind",
|
||||
description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları",
|
||||
async up(db) {
|
||||
await db
|
||||
.collection("transactions")
|
||||
.updateMany(
|
||||
{ inKindDebtId: { $exists: false } },
|
||||
{ $set: { inKindDebtId: null, inKindRole: null } },
|
||||
);
|
||||
await AssetPriceModel.createIndexes();
|
||||
await InKindDebtModel.createIndexes();
|
||||
},
|
||||
};
|
||||
@@ -7,10 +7,11 @@ import { migration as m004 } from "./004-attachments-reconciliation";
|
||||
import { migration as m005 } from "./005-recurring";
|
||||
import { migration as m006 } from "./006-card-statements";
|
||||
import { migration as m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007];
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { addContribution, createInKind, savePrice } from "@/features/in-kind/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { ContactModel } from "@/models/contact";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import type { AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
|
||||
/**
|
||||
* Örnek fiyat tablosu ve aynî kayıtlar (Faz 8b). Fiyatlar örnek amaçlıdır (gerçek piyasa verisi
|
||||
* değildir): son 6 ay, ayda bir kayıt, doğrusal artış. İdempotent.
|
||||
*/
|
||||
|
||||
/** [6 ay önceki alış, bugünkü alış, satış farkı] — birim başına kuruş. */
|
||||
const PRICE_PATH: Partial<Record<AssetKey, [number, number, number]>> = {
|
||||
gram_gold: [4_900_00, 5_600_00, 100_00],
|
||||
quarter_gold: [8_050_00, 9_200_00, 250_00],
|
||||
usd: [40_20, 42_80, 30],
|
||||
eur: [46_90, 49_50, 40],
|
||||
};
|
||||
|
||||
async function seedPrices(householdId: Types.ObjectId, actorId: string, today: string) {
|
||||
for (const [k, [from, to, spread]] of Object.entries(PRICE_PATH)) {
|
||||
const key = k as AssetKey;
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = i === 0 ? today : addMonths(today, -i);
|
||||
if (await AssetPriceModel.exists({ householdId, assetKey: key, date })) continue;
|
||||
const buy = Math.round(to - ((to - from) * i) / 6);
|
||||
await savePrice(
|
||||
actorId,
|
||||
String(householdId),
|
||||
assetPriceFormSchema.parse({
|
||||
assetKey: key,
|
||||
date,
|
||||
buy: formatMoneyInput(buy),
|
||||
sell: formatMoneyInput(buy + spread),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SeedInKind = {
|
||||
ref: string;
|
||||
household: string;
|
||||
contact: string;
|
||||
by: string;
|
||||
direction: "debt" | "receivable";
|
||||
assetKey: AssetKey;
|
||||
quantity: string;
|
||||
daysAgo: number;
|
||||
note?: string;
|
||||
savings?: string;
|
||||
monthlyPlanMinor?: number;
|
||||
contributions?: { from: string; amountMinor: number; daysAgo: number }[];
|
||||
};
|
||||
|
||||
const ITEMS: SeedInKind[] = [
|
||||
{
|
||||
ref: "hasan-altin",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:hasan",
|
||||
by: "admin",
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
daysAgo: 95,
|
||||
note: "Düğün için ödünç alınan altın",
|
||||
savings: "Birikim (Vadeli)",
|
||||
monthlyPlanMinor: 4_000_00,
|
||||
contributions: [
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 60 },
|
||||
{ from: "Ziraat Maaş", amountMinor: 4_000_00, daysAgo: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: "elif-usd",
|
||||
household: "Çakır Ailesi",
|
||||
contact: "contact:elif",
|
||||
by: "ayse",
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
daysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "ali-ceyrek",
|
||||
household: "Annemler",
|
||||
contact: "contact:ali",
|
||||
by: "fatma",
|
||||
direction: "receivable",
|
||||
assetKey: "quarter_gold",
|
||||
quantity: "2",
|
||||
daysAgo: 150,
|
||||
note: "Torun doğumunda verilen çeyrekler",
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedInKind(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const today = todayIstanbul();
|
||||
await seedPrices(householdIds.get("Çakır Ailesi")!, userIds.get("admin")!, today);
|
||||
await seedPrices(householdIds.get("Annemler")!, userIds.get("fatma")!, today);
|
||||
let created = 0;
|
||||
for (const x of ITEMS) {
|
||||
const householdId = householdIds.get(x.household)!;
|
||||
const seedRef = `inkind:${x.ref}`;
|
||||
if (await InKindDebtModel.exists({ householdId, seedRef })) continue;
|
||||
const contact = await ContactModel.findOne({ householdId, seedRef: x.contact }).lean();
|
||||
if (!contact) continue;
|
||||
const account = async (name: string) =>
|
||||
String((await AccountModel.findOne({ householdId, name }).lean())!._id);
|
||||
const by = userIds.get(x.by)!;
|
||||
const r = await createInKind(
|
||||
by,
|
||||
String(householdId),
|
||||
String(contact._id),
|
||||
inKindFormSchema.parse({
|
||||
direction: x.direction,
|
||||
assetKey: x.assetKey,
|
||||
quantity: x.quantity,
|
||||
date: addDays(today, -x.daysAgo),
|
||||
note: x.note ?? "",
|
||||
savingsAccountId: x.savings ? await account(x.savings) : "",
|
||||
monthlyPlan: x.monthlyPlanMinor ? formatMoneyInput(x.monthlyPlanMinor) : "",
|
||||
}),
|
||||
);
|
||||
if (!r.ok) throw new Error(`Seed aynî kaydı oluşturulamadı (${x.ref}): ${r.error}`);
|
||||
await InKindDebtModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
|
||||
for (const c of x.contributions ?? []) {
|
||||
const res = await addContribution(
|
||||
by,
|
||||
String(householdId),
|
||||
r.data.id,
|
||||
contributionSchema.parse({
|
||||
fromAccountId: await account(c.from),
|
||||
amount: formatMoneyInput(c.amountMinor),
|
||||
date: addDays(today, -c.daysAgo),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`Seed birikimi oluşturulamadı (${x.ref}): ${res.error}`);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ describe("seed", () => {
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import { storedEmail } from "@/shared/account-email";
|
||||
import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
import { seedTransactions } from "./transactions";
|
||||
@@ -49,6 +50,7 @@ export type SeedSummary = {
|
||||
recurringRules: number;
|
||||
installmentPlans: number;
|
||||
contacts: number;
|
||||
inKind: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -67,6 +69,8 @@ const DOMAIN_COLLECTIONS = [
|
||||
"recurringRules",
|
||||
"statements",
|
||||
"contacts",
|
||||
"assetPrices",
|
||||
"inKindDebts",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -119,6 +123,7 @@ export async function runSeed({
|
||||
recurringRules: 0,
|
||||
installmentPlans: 0,
|
||||
contacts: 0,
|
||||
inKind: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -295,6 +300,8 @@ export async function runSeed({
|
||||
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||
summary.created.contacts = await seedContacts(householdIds, userIdMap);
|
||||
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
|
||||
summary.created.inKind = await seedInKind(householdIds, userIdMap);
|
||||
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ContactStatementView } from "@/features/contacts/components/contact-statement-view";
|
||||
import { getContactStatement } from "@/features/contacts/service";
|
||||
import { listInKind } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
@@ -11,7 +12,10 @@ export const metadata: Metadata = { title: tr.contacts.statement };
|
||||
export default async function ContactPage({ params }: PageProps<"/cari/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const data = await getContactStatement(user.id, household.id, id);
|
||||
const [data, inKind] = await Promise.all([
|
||||
getContactStatement(user.id, household.id, id),
|
||||
listInKind(user.id, household.id, id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return <ContactStatementView key={id} data={data} />;
|
||||
return <ContactStatementView key={id} data={data} inKind={inKind ?? []} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ContactsView } from "@/features/contacts/components/contacts-view";
|
||||
import { listContacts } from "@/features/contacts/service";
|
||||
import { openInKindByContact } from "@/features/in-kind/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
@@ -11,12 +12,19 @@ export const metadata: Metadata = { title: tr.contacts.title };
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listContacts(user.id, household.id);
|
||||
const [data, inKind] = await Promise.all([
|
||||
listContacts(user.id, household.id),
|
||||
openInKindByContact(user.id, household.id),
|
||||
]);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.contacts.title} description={tr.contacts.description} />
|
||||
<ContactsView data={data} canWrite={hasHouseholdRole(household.role, "member")} />
|
||||
<ContactsView
|
||||
data={data}
|
||||
inKind={Object.fromEntries(inKind)}
|
||||
canWrite={hasHouseholdRole(household.role, "member")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PricesView } from "@/features/in-kind/components/prices-view";
|
||||
import { listPrices } from "@/features/in-kind/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.prices.title };
|
||||
|
||||
/** /fiyatlar — altın ve döviz fiyat tablosu (elle; Faz 9 varlık takibi de kullanır). */
|
||||
export default async function PricesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const data = await listPrices(user.id, household.id);
|
||||
if (!data) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.prices.title} description={tr.prices.description} />
|
||||
<PricesView rows={data.rows} canWrite={data.canWrite} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 8.1;
|
||||
export const CURRENT_PHASE = 8.2;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { InKindSection } from "@/features/in-kind/components/in-kind-section";
|
||||
import type { InKindView } from "@/features/in-kind/service";
|
||||
import type { ContactStatement } from "../service";
|
||||
import { ContactEntryDialog, ContactFormDialog, type EntryMode } from "./contact-dialogs";
|
||||
import { ContactBalance, DueBadge } from "./contacts-view";
|
||||
@@ -31,7 +33,13 @@ function Delta({ minor }: { minor: number }) {
|
||||
}
|
||||
|
||||
/** Cari ekstresi: hareketler (yeniden eskiye) ve yürüyen bakiye (+ bana borçlu, − ben borçluyum). */
|
||||
export function ContactStatementView({ data }: { data: ContactStatement }) {
|
||||
export function ContactStatementView({
|
||||
data,
|
||||
inKind,
|
||||
}: {
|
||||
data: ContactStatement;
|
||||
inKind: InKindView[];
|
||||
}) {
|
||||
const c = data.contact;
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -90,6 +98,8 @@ export function ContactStatementView({ data }: { data: ContactStatement }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<InKindSection contactId={c.id} items={inKind} canWrite={canWrite} />
|
||||
|
||||
<h2 className="text-base font-medium">{t.statement}</h2>
|
||||
{data.rows.length === 0 && c.openingBalanceMinor === 0 ? (
|
||||
<Card>
|
||||
|
||||
@@ -60,7 +60,16 @@ export function DueBadge({ contact }: { contact: ContactView }) {
|
||||
return <Badge variant="outline">{t.due.on(d(contact.dueDate))}</Badge>;
|
||||
}
|
||||
|
||||
export function ContactsView({ data, canWrite }: { data: ContactList; canWrite: boolean }) {
|
||||
export function ContactsView({
|
||||
data,
|
||||
inKind,
|
||||
canWrite,
|
||||
}: {
|
||||
data: ContactList;
|
||||
/** Açık aynî kayıtlar (cari kimliği → yön ve kalan miktar). */
|
||||
inKind: Record<string, { direction: "debt" | "receivable"; label: string }[]>;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<{ contact: ContactView | null } | null>(null);
|
||||
const [entry, setEntry] = useState<EntryMode | null>(null);
|
||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||
@@ -94,6 +103,11 @@ export function ContactsView({ data, canWrite }: { data: ContactList; canWrite:
|
||||
<span>{t.relations[c.relation]}</span>
|
||||
{c.lastActivity ? <span>· {t.lastActivity(d(c.lastActivity))}</span> : null}
|
||||
<DueBadge contact={c} />
|
||||
{(inKind[c.id] ?? []).map((x, i) => (
|
||||
<Badge key={i} variant={x.direction === "debt" ? "destructive" : "secondary"}>
|
||||
{x.label}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<ContactBalance minor={c.balanceMinor} />
|
||||
|
||||
@@ -65,6 +65,8 @@ async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.Obj
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
contactId: contactIds ? { $in: contactIds } : { $type: "objectId" },
|
||||
// Aynî borç hareketleri cari TL bakiyesine girmez.
|
||||
contactKind: { $ne: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -404,6 +406,7 @@ export async function getContactStatement(
|
||||
const txs = (await TransactionModel.find({
|
||||
householdId: ctx.m.householdId,
|
||||
contactId: contact._id,
|
||||
contactKind: { $ne: null },
|
||||
status: "posted",
|
||||
deletedAt: null,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
deleteInKind,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
updateInKind,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revalidate = () => revalidatePath("/", "layout");
|
||||
|
||||
async function run<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidate();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function savePriceAction(input: unknown) {
|
||||
return run(assetPriceFormSchema, input, savePrice);
|
||||
}
|
||||
|
||||
/** Yeni aynî kayıt (debtId boş, contactId dolu) veya mevcut kaydı düzenle. */
|
||||
export async function saveInKindAction(contactId: unknown, debtId: unknown, input: unknown) {
|
||||
if (debtId) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => updateInKind(u, h, id.data, d));
|
||||
}
|
||||
const cid = idSchema.safeParse(contactId);
|
||||
if (!cid.success) return fail(tr.common.notFound);
|
||||
return run(inKindFormSchema, input, (u, h, d) => createInKind(u, h, cid.data, d));
|
||||
}
|
||||
|
||||
export async function deleteInKindAction(debtId: unknown): Promise<ActionResult<unknown>> {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteInKind(u, h, id.data));
|
||||
}
|
||||
|
||||
export async function addContributionAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(contributionSchema, input, (u, h, d) => addContribution(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function recordPaymentAction(debtId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(debtId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(paymentFormSchema, input, (u, h, d) => recordPayment(u, h, id.data, d));
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getTransactionFormOptionsAction } from "@/features/transactions/actions";
|
||||
import { AccountSelect } from "@/features/transactions/components/transaction-form";
|
||||
import type { TransactionFormOptions } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import {
|
||||
addContributionAction,
|
||||
deleteInKindAction,
|
||||
recordPaymentAction,
|
||||
saveInKindAction,
|
||||
} from "../actions";
|
||||
import type { InKindView } from "../service";
|
||||
|
||||
const t = tr.inKind;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "Europe/Istanbul",
|
||||
});
|
||||
const qtyInput = (q: number) =>
|
||||
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
|
||||
|
||||
/** Hesap seçenekleri (diyaloglar açıldığında). */
|
||||
function useAccounts() {
|
||||
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTransactionFormOptionsAction().then((r) => {
|
||||
if (!cancelled && r.ok) setOptions(r.data);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return options?.accounts ?? null;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending =
|
||||
| { kind: "form"; item: InKindView | null }
|
||||
| { kind: "contribute"; item: InKindView }
|
||||
| { kind: "pay"; item: InKindView }
|
||||
| { kind: "delete"; item: InKindView }
|
||||
| null;
|
||||
|
||||
/** Cari sayfasında aynî borç/alacak bölümü. */
|
||||
export function InKindSection({
|
||||
contactId,
|
||||
items,
|
||||
canWrite,
|
||||
}: {
|
||||
contactId: string;
|
||||
items: InKindView[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-labelledby="in-kind-title">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="in-kind-title" className="text-base font-medium">
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/fiyatlar">
|
||||
<Coins />
|
||||
{t.prices}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "form", item: null })}
|
||||
>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => (
|
||||
<InKindCard key={item.id} item={item} canWrite={canWrite} onAction={setPending} />
|
||||
))}
|
||||
|
||||
{pending?.kind === "form" ? (
|
||||
<InKindForm contactId={contactId} item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "contribute" ? (
|
||||
<ContributionForm item={pending.item} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "pay" ? <PaymentForm item={pending.item} onClose={close} /> : null}
|
||||
<AlertDialog open={pending?.kind === "delete"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "delete") return;
|
||||
const r = await deleteInKindAction(pending.item.id);
|
||||
close();
|
||||
if (r.ok) toast.success(t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.delete}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindCard({
|
||||
item,
|
||||
canWrite,
|
||||
onAction,
|
||||
}: {
|
||||
item: InKindView;
|
||||
canWrite: boolean;
|
||||
onAction: (p: Pending) => void;
|
||||
}) {
|
||||
const p = item.progress;
|
||||
const debt = item.direction === "debt";
|
||||
const open = !item.closedAt;
|
||||
return (
|
||||
<Card size="sm" className={cn(!open && "opacity-70")}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
|
||||
{item.label}
|
||||
<Badge variant={debt ? "destructive" : "secondary"}>
|
||||
{t.directions[item.direction]}
|
||||
</Badge>
|
||||
{item.closedAt ? (
|
||||
<Badge variant="outline">{t.stats.closed(d(item.closedAt))}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t.stats.dateValue(d(item.date))}:{" "}
|
||||
{item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"}
|
||||
{item.unitPriceMinor !== null
|
||||
? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAction({ kind: "form", item })}>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onAction({ kind: "delete", item })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t.stats.remaining}
|
||||
value={formatAssetQuantity(item.assetKey, p.remainingQuantity)}
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.currentValue}
|
||||
value={
|
||||
p.currentValueMinor !== null ? formatMoney(p.currentValueMinor) : t.stats.noPrice
|
||||
}
|
||||
tone={debt ? "text-expense" : "text-income"}
|
||||
/>
|
||||
{item.savingsAccount ? (
|
||||
<>
|
||||
<Stat
|
||||
label={`${t.stats.accumulated} · ${item.savingsAccount.name}`}
|
||||
value={formatMoney(p.accumulatedMinor)}
|
||||
tone="text-income"
|
||||
/>
|
||||
<Stat
|
||||
label={t.stats.remainingNeed}
|
||||
value={p.remainingNeedMinor !== null ? formatMoney(p.remainingNeedMinor) : "—"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{p.estimatedCloseMonth && open ? (
|
||||
<p className="text-sm">
|
||||
{t.stats.estimatedClose}:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))}
|
||||
</span>
|
||||
{item.monthlyPlanMinor ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {formatMoney(item.monthlyPlanMinor)}/ay
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{item.currentPrice ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.stats.currentPrice(
|
||||
debt,
|
||||
formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor),
|
||||
d(item.currentPrice.date),
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{item.note ? <p className="text-sm text-muted-foreground">{item.note}</p> : null}
|
||||
{item.payments.length ? (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">{t.payments}</p>
|
||||
<ul className="divide-y rounded-lg border text-sm">
|
||||
{item.payments.map((x) => (
|
||||
<li key={x.id} className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="min-w-0 truncate">
|
||||
{d(x.date)} ·{" "}
|
||||
{t.paymentLine(
|
||||
t.methods[x.method],
|
||||
formatAssetQuantity(item.assetKey, x.quantity),
|
||||
x.amountMinor ? formatMoney(x.amountMinor) : null,
|
||||
)}
|
||||
</span>
|
||||
{x.account ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{x.account}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{canWrite && open ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{debt && item.savingsAccount ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAction({ kind: "contribute", item })}
|
||||
>
|
||||
<PiggyBank />
|
||||
{t.contribute}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" onClick={() => onAction({ kind: "pay", item })}>
|
||||
<HandCoins />
|
||||
{t.pay}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InKindForm({
|
||||
contactId,
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
contactId: string;
|
||||
item: InKindView | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
direction: item?.direction ?? "debt",
|
||||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||||
quantity: item ? qtyInput(item.quantity) : "",
|
||||
date: item?.date ?? todayIstanbul(),
|
||||
unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "",
|
||||
note: item?.note ?? "",
|
||||
savingsAccountId: item?.savingsAccount?.id ?? "",
|
||||
monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const NONE = "__none__";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveInKindAction(contactId, item?.id ?? null, {
|
||||
...v,
|
||||
savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "",
|
||||
monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="in-kind-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="in-kind-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={v.direction}
|
||||
onValueChange={(x) => set("direction", x as "debt" | "receivable")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(["debt", "receivable"] as const).map((x) => (
|
||||
<TabsTrigger
|
||||
key={x}
|
||||
value={x}
|
||||
className="flex-1"
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
{t.directions[x]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-[1fr_8rem] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-asset">{f.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||||
disabled={Boolean(item?.payments.length)}
|
||||
>
|
||||
<SelectTrigger id="ik-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="ik-qty">
|
||||
{f.quantity} ({ASSETS[v.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="ik-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="ik-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="ik-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||||
<FieldLabel htmlFor="ik-price">{f.unitPrice}</FieldLabel>
|
||||
<MoneyInput id="ik-price" value={v.unitPrice} onChange={(x) => set("unitPrice", x)} />
|
||||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{f.unitPriceHint}</FieldDescription>
|
||||
{v.direction === "debt" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.savingsAccountId)}>
|
||||
<FieldLabel htmlFor="ik-savings">{f.savingsAccount}</FieldLabel>
|
||||
<Select
|
||||
value={v.savingsAccountId || NONE}
|
||||
onValueChange={(x) => set("savingsAccountId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="ik-savings" className="w-full">
|
||||
<SelectValue>
|
||||
{accounts?.find((a) => a.id === v.savingsAccountId)?.name ??
|
||||
(v.savingsAccountId ? item?.savingsAccount?.name : f.savingsNone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{f.savingsNone}</SelectItem>
|
||||
{(accounts ?? [])
|
||||
.filter((a) => a.type !== "credit_card")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.savingsAccountId ? (
|
||||
<FieldError>{errors.savingsAccountId}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-plan">{f.monthlyPlan}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ik-plan"
|
||||
value={v.monthlyPlan}
|
||||
onChange={(x) => set("monthlyPlan", x)}
|
||||
disabled={!v.savingsAccountId}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
{v.direction === "debt" ? (
|
||||
<FieldDescription className="-mt-2">{f.planHint}</FieldDescription>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ik-note">{f.note}</FieldLabel>
|
||||
<Input id="ik-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContributionForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const [v, setV] = useState({
|
||||
fromAccountId: "",
|
||||
amount: item.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
|
||||
date: todayIstanbul(),
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sources = (accounts ?? []).filter((a) => a.id !== item.savingsAccount?.id);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addContributionAction(item.id, {
|
||||
...v,
|
||||
fromAccountId: v.fromAccountId || sources[0]?.id || "",
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.contributed);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.contributionTitle} · ${item.label}`}
|
||||
description={`→ ${item.savingsAccount?.name ?? ""}`}
|
||||
footer={<Footer busy={busy} formId="contribution-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="contribution-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.fromAccountId)}>
|
||||
<FieldLabel htmlFor="ct-from">{t.fromAccount}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="ct-from"
|
||||
value={v.fromAccountId || sources[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, fromAccountId: x }))}
|
||||
accounts={sources}
|
||||
/>
|
||||
{errors.fromAccountId ? <FieldError>{errors.fromAccountId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="ct-amount">{tr.transactions.fields.amount}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="ct-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ct-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="ct-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const debt = item.direction === "debt";
|
||||
const [method, setMethod] = useState<"asset" | "cash">("cash");
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity: qtyInput(item.progress.remainingQuantity),
|
||||
amount: "",
|
||||
accountId: item.savingsAccount?.id ?? "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await recordPaymentAction(item.id, {
|
||||
method,
|
||||
date: v.date,
|
||||
quantity: method === "cash" ? "" : v.quantity,
|
||||
amount: v.amount,
|
||||
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
|
||||
note: v.note,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success((r.data as { closed: boolean }).closed ? t.toast.closed : t.toast.paid);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
const f = t.payFields;
|
||||
const hints = debt ? t.methodHints : t.receiveHints;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.payTitle} · ${item.label}`}
|
||||
description={`${t.stats.remaining}: ${formatAssetQuantity(item.assetKey, item.progress.remainingQuantity)}`}
|
||||
footer={<Footer busy={busy} formId="payment-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="payment-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs value={method} onValueChange={(x) => setMethod(x as "asset" | "cash")}>
|
||||
<TabsList className="w-full">
|
||||
{(["cash", "asset"] as const).map((m) => (
|
||||
<TabsTrigger key={m} value={m} className="flex-1">
|
||||
{t.methods[m]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<p className="-mt-2 text-xs text-muted-foreground">{hints[method]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{method === "asset" ? (
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="pay-qty">
|
||||
{f.quantity} ({ASSETS[item.assetKey].unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pay-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => setV((s) => ({ ...s, quantity: e.target.value }))}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field data-invalid={Boolean(errors.amount)}>
|
||||
<FieldLabel htmlFor="pay-amount">
|
||||
{method === "cash" ? f.amount : f.assetAmount}
|
||||
</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pay-amount"
|
||||
value={v.amount}
|
||||
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
|
||||
/>
|
||||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
|
||||
<Input
|
||||
id="pay-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
|
||||
) : null}
|
||||
{method === "cash" || v.amount ? (
|
||||
<Field data-invalid={Boolean(errors.accountId)}>
|
||||
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
|
||||
<AccountSelect
|
||||
id="pay-account"
|
||||
value={v.accountId || accounts?.[0]?.id || ""}
|
||||
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
|
||||
accounts={accounts ?? []}
|
||||
invalid={Boolean(errors.accountId)}
|
||||
/>
|
||||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pay-note">{t.fields.note}</FieldLabel>
|
||||
<Input
|
||||
id="pay-note"
|
||||
value={v.note}
|
||||
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MoneyInput } from "@/components/money-input";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { savePriceAction } from "../actions";
|
||||
import type { PriceRow } from "../service";
|
||||
|
||||
const t = tr.prices;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function PricesView({ rows, canWrite }: { rows: PriceRow[]; canWrite: boolean }) {
|
||||
const [editing, setEditing] = useState<AssetKey | null>(null);
|
||||
const [openRow, setOpenRow] = useState<AssetKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing("gram_gold")}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-normal">{t.asset}</th>
|
||||
<th className="p-3 text-right font-normal">{t.buy}</th>
|
||||
<th className="p-3 text-right font-normal">{t.sell}</th>
|
||||
<th className="p-3 text-right font-normal">{t.date}</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{rows.map((r) => (
|
||||
<Fragment key={r.assetKey}>
|
||||
<tr>
|
||||
<td className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-left font-medium disabled:cursor-default"
|
||||
onClick={() => setOpenRow(openRow === r.assetKey ? null : r.assetKey)}
|
||||
disabled={r.history.length < 2}
|
||||
aria-expanded={openRow === r.assetKey}
|
||||
>
|
||||
{r.history.length > 1 ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
openRow === r.assetKey && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ASSETS[r.assetKey].label}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t.perUnit(ASSETS[r.assetKey].unit)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.buyMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.sellMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{r.latest ? d(r.latest.date) : t.noPrice}
|
||||
</td>
|
||||
<td className="p-1 text-right">
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(r.assetKey)}
|
||||
aria-label={`${t.add}: ${ASSETS[r.assetKey].label}`}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{openRow === r.assetKey
|
||||
? r.history.slice(1).map((h) => (
|
||||
<tr key={h.date} className="bg-muted/30 text-muted-foreground">
|
||||
<td className="p-2 pl-9 text-xs">{t.history}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.buyMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.sellMinor)}</td>
|
||||
<td className="p-2 text-right">{d(h.date)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editing ? (
|
||||
<PriceForm
|
||||
key={editing}
|
||||
assetKey={editing}
|
||||
latest={rows.find((r) => r.assetKey === editing)?.latest ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceForm({
|
||||
assetKey: initial,
|
||||
latest,
|
||||
onClose,
|
||||
}: {
|
||||
assetKey: AssetKey;
|
||||
latest: PriceRow["latest"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: initial,
|
||||
date: todayIstanbul(),
|
||||
buy: latest ? formatMoneyInput(latest.buyMinor) : "",
|
||||
sell: latest ? formatMoneyInput(latest.sellMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await savePriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.add}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-asset">{t.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => setV((s) => ({ ...s, assetKey: x as AssetKey }))}
|
||||
>
|
||||
<SelectTrigger id="pr-asset" className="w-full">
|
||||
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSET_KEYS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{ASSETS[k].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="pr-date">{t.date}</FieldLabel>
|
||||
<Input
|
||||
id="pr-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.buy)}>
|
||||
<FieldLabel htmlFor="pr-buy">{t.buy}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-buy"
|
||||
value={v.buy}
|
||||
onChange={(x) => setV((s) => ({ ...s, buy: x }))}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.buy ? <FieldError>{errors.buy}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-sell">{t.sell}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-sell"
|
||||
value={v.sell}
|
||||
onChange={(x) => setV((s) => ({ ...s, sell: x }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{t.sellHint}</FieldDescription>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { createContact, listContacts } from "@/features/contacts/service";
|
||||
import { listTransactions, setTransactionDeleted } from "@/features/transactions/service";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { AssetPriceModel } from "@/models/asset-price";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { contactFormSchema } from "@/shared/schemas/contact";
|
||||
import {
|
||||
assetPriceFormSchema,
|
||||
contributionSchema,
|
||||
inKindFormSchema,
|
||||
paymentFormSchema,
|
||||
} from "@/shared/schemas/in-kind";
|
||||
import { transactionFilterSchema } from "@/shared/schemas/transaction";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addContribution,
|
||||
createInKind,
|
||||
listInKind,
|
||||
listPrices,
|
||||
recordPayment,
|
||||
savePrice,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let emre = "";
|
||||
let zeynep = "";
|
||||
const today = todayIstanbul();
|
||||
const monthAgo = addDays(today, -30);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
emre = ids.user("emre");
|
||||
zeynep = ids.user("zeynep");
|
||||
// Seed örnek fiyatları bu testin fiyat senaryolarını etkilemesin.
|
||||
await AssetPriceModel.deleteMany({});
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const acc = (name: string) =>
|
||||
AccountModel.findOne({ householdId: cakir, name })
|
||||
.lean()
|
||||
.then((a) => String(a!._id));
|
||||
async function balance(accountId: string) {
|
||||
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
|
||||
}
|
||||
const price = (assetKey: string, date: string, buy: string, sell = "") =>
|
||||
assetPriceFormSchema.parse({ assetKey, date, buy, sell });
|
||||
async function contact(name: string, isPrivate = false) {
|
||||
const r = await createContact(
|
||||
ayse,
|
||||
cakir,
|
||||
contactFormSchema.parse({ name, openingDate: monthAgo, isPrivate }),
|
||||
);
|
||||
return r.ok ? r.data.id : "";
|
||||
}
|
||||
async function view(id: string) {
|
||||
return (await listInKind(ayse, cakir))!.find((v) => v.id === id)!;
|
||||
}
|
||||
|
||||
describe("fiyat tablosu", () => {
|
||||
it("elle fiyat; tek fiyat ikisine yazılır; gelecek tarih reddedilir; izleyici yazamaz", async () => {
|
||||
expect((await savePrice(ayse, cakir, price("gram_gold", today, "3.200", "3.300"))).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect((await savePrice(ayse, cakir, price("usd", today, "41,50"))).ok).toBe(true);
|
||||
expect((await savePrice(ayse, cakir, price("usd", addDays(today, 1), "1"))).ok).toBe(false);
|
||||
expect((await savePrice(zeynep, cakir, price("usd", today, "1"))).ok).toBe(false);
|
||||
const rows = (await listPrices(ayse, cakir))!.rows;
|
||||
expect(rows.find((r) => r.assetKey === "usd")!.latest).toMatchObject({
|
||||
buyMinor: 41_50,
|
||||
sellMinor: 41_50,
|
||||
});
|
||||
// Aynı gün yeniden giriş günceller.
|
||||
await savePrice(ayse, cakir, price("usd", today, "42"));
|
||||
expect(
|
||||
(await listPrices(ayse, cakir))!.rows.find((r) => r.assetKey === "usd")!.history,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç", () => {
|
||||
let gold = "";
|
||||
let ahmet = "";
|
||||
|
||||
it("borç tarihindeki fiyat tablodan; yoksa birim fiyat istenir; değer satış fiyatıyla", async () => {
|
||||
ahmet = await contact("Altın Ahmet");
|
||||
const input = inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "5",
|
||||
date: monthAgo,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
monthlyPlan: "2.000",
|
||||
});
|
||||
expect((await createInKind(ayse, cakir, ahmet, input)).ok).toBe(false);
|
||||
await savePrice(ayse, cakir, price("gram_gold", monthAgo, "2.900", "3.000"));
|
||||
const r = await createInKind(ayse, cakir, ahmet, input);
|
||||
expect(r.ok).toBe(true);
|
||||
gold = r.ok ? r.data.id : "";
|
||||
const v = await view(gold);
|
||||
expect(v).toMatchObject({
|
||||
unitPriceMinor: 3_000_00,
|
||||
valueMinor: 15_000_00,
|
||||
label: "5 gr Gram altın (24 ayar)",
|
||||
});
|
||||
// Güncel: 5 × 3.300 (satış) = 16.500; birikim 0; ayda 2.000 → 9 ay.
|
||||
expect(v.progress).toMatchObject({
|
||||
currentValueMinor: 16_500_00,
|
||||
accumulatedMinor: 0,
|
||||
remainingNeedMinor: 16_500_00,
|
||||
estimatedCloseMonth: addMonths(`${today.slice(0, 7)}-01`, 9).slice(0, 7),
|
||||
});
|
||||
});
|
||||
|
||||
it("birikim katkısı: hesaptan birikim hesabına transfer; borç kapanmaz; cari TL bakiyesi ve gelir/gider değişmez", async () => {
|
||||
const ziraat = await acc("Ziraat Maaş");
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const [z0, b0] = [await balance(ziraat), await balance(birikim)];
|
||||
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
|
||||
const r = await addContribution(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
contributionSchema.parse({ fromAccountId: ziraat, amount: "6.000", date: today }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(await balance(ziraat)).toBe(z0 - 6_000_00);
|
||||
expect(await balance(birikim)).toBe(b0 + 6_000_00);
|
||||
const v = await view(gold);
|
||||
expect(v.progress.accumulatedMinor).toBe(6_000_00);
|
||||
expect(v.progress.remainingNeedMinor).toBe(10_500_00);
|
||||
expect(v.closedAt).toBeNull();
|
||||
expect(
|
||||
(await listContacts(ayse, cakir))!.contacts.find((c) => c.id === ahmet)!.balanceMinor,
|
||||
).toBe(0);
|
||||
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
|
||||
sums0,
|
||||
);
|
||||
});
|
||||
|
||||
it("TL karşılığıyla kısmi ödeme: miktar güncel fiyattan; birikim hesabından çıkış", async () => {
|
||||
const birikim = await acc("Birikim (Vadeli)");
|
||||
const b0 = await balance(birikim);
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "6.600", accountId: birikim }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: false } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(2_000);
|
||||
expect(v.progress.remainingQuantity).toBe(3_000);
|
||||
expect(v.progress.accumulatedMinor).toBe(0);
|
||||
expect(await balance(birikim)).toBe(b0 - 6_600_00);
|
||||
// Ödeme işlemi tek başına silinemez.
|
||||
const doc = (await InKindDebtModel.findById(gold).lean())!;
|
||||
expect(
|
||||
(await setTransactionDeleted(ayse, cakir, String(doc.payments[0]!.transactionId), true)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynı varlıkla ödeme kalanı aşarsa kalana sınırlanır ve kayıt kapanır", async () => {
|
||||
const r = await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "5" }),
|
||||
);
|
||||
expect(r).toMatchObject({ ok: true, data: { closed: true } });
|
||||
const v = await view(gold);
|
||||
expect(v.paidQuantity).toBe(5_000);
|
||||
expect(v.closedAt).toBe(today);
|
||||
expect(await AuditLogModel.exists({ action: "inkind.close", targetId: gold })).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
gold,
|
||||
paymentFormSchema.parse({ method: "asset", date: today, quantity: "1" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aynî alacak: alış fiyatıyla değerlenir, tahsilat hesaba girer; birikim planı alınmaz", async () => {
|
||||
const elif = await contact("Döviz Elif");
|
||||
const cuzdan = await acc("Cüzdan");
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
savingsAccountId: await acc("Birikim (Vadeli)"),
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
const r = await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
elif,
|
||||
inKindFormSchema.parse({
|
||||
direction: "receivable",
|
||||
assetKey: "usd",
|
||||
quantity: "100",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await view(id)).progress.currentValueMinor).toBe(4_200_00);
|
||||
const c0 = await balance(cuzdan);
|
||||
await recordPayment(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
paymentFormSchema.parse({ method: "cash", date: today, amount: "2.100", accountId: cuzdan }),
|
||||
);
|
||||
expect(await balance(cuzdan)).toBe(c0 + 2_100_00);
|
||||
expect((await view(id)).paidQuantity).toBe(50_000);
|
||||
});
|
||||
|
||||
it("gizli cari: aynî kayıt başkasına görünmez ve eklenemez", async () => {
|
||||
const secret = await contact("Gizli Altın", true);
|
||||
await createInKind(
|
||||
ayse,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
);
|
||||
expect((await listInKind(emre, cakir))!.some((v) => v.contactId === secret)).toBe(false);
|
||||
expect(await listInKind(emre, cakir, secret)).toBeNull();
|
||||
expect(
|
||||
(
|
||||
await createInKind(
|
||||
emre,
|
||||
cakir,
|
||||
secret,
|
||||
inKindFormSchema.parse({
|
||||
direction: "debt",
|
||||
assetKey: "gram_gold",
|
||||
quantity: "1",
|
||||
date: today,
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -532,6 +532,8 @@ export async function setTransactionDeleted(
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true });
|
||||
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
||||
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
||||
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: current._id, householdId: ctx.m.householdId },
|
||||
|
||||
@@ -201,8 +201,13 @@ export async function getAccountStatement(
|
||||
const effects = rowsAsc.map((tx) => {
|
||||
const item = toItem(ctx, tx, names);
|
||||
const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : [];
|
||||
const hiddenContact = Boolean(item.contact && ctx.contactMap.get(item.contact.id)?.hidden);
|
||||
if (hiddenContact && item.contact) item.contact.name = tr.transactions.list.hiddenContact;
|
||||
// Gizli cariye bağlı hareket (cari hareketi veya aynî borç ödemesi): kişi adı ve açıklama gizlenir.
|
||||
const hiddenContact = Boolean(tx.contactId && ctx.contactMap.get(String(tx.contactId))?.hidden);
|
||||
if (hiddenContact) {
|
||||
if (item.contact) item.contact.name = tr.transactions.list.hiddenContact;
|
||||
item.description = "";
|
||||
item.payeeName = tr.transactions.list.hiddenContact;
|
||||
}
|
||||
const restricted =
|
||||
hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden);
|
||||
if (restricted) {
|
||||
|
||||
+106
@@ -363,6 +363,7 @@ export const tr = {
|
||||
reconciled: "Mutabakatı yapıldı",
|
||||
cancelInstallments: "Kalan taksitleri iptal et",
|
||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||
noAccountsLink: "Hesaplara git",
|
||||
@@ -549,6 +550,111 @@ export const tr = {
|
||||
archived: "Arşivlenmiş cariye hareket eklenemez.",
|
||||
},
|
||||
},
|
||||
inKind: {
|
||||
title: "Aynî borç ve alacaklar",
|
||||
description:
|
||||
"Altın, ziynet veya döviz cinsinden borç/alacak. Kapanış yalnızca “Ödeme gerçekleşti” ile.",
|
||||
new: "Aynî kayıt ekle",
|
||||
createTitle: "Yeni aynî borç/alacak",
|
||||
editTitle: "Aynî kaydı düzenle",
|
||||
directions: { debt: "Ben borçluyum", receivable: "Bana borçlu" },
|
||||
fields: {
|
||||
direction: "Yön",
|
||||
asset: "Varlık",
|
||||
quantity: "Miktar",
|
||||
date: "Borç tarihi",
|
||||
unitPrice: "O tarihteki birim fiyat (TL)",
|
||||
unitPriceHint: "Boş bırakılırsa fiyat tablosundaki o tarihe ait son fiyat kullanılır.",
|
||||
note: "Not",
|
||||
savingsAccount: "Birikim hesabı",
|
||||
savingsNone: "— Birikim planı yok —",
|
||||
monthlyPlan: "Aylık ayrılacak tutar (TL)",
|
||||
planHint:
|
||||
"Borcu kapatmak için her ay bu hesaba para ayırın; borç yalnızca ödeme yapılınca kapanır.",
|
||||
},
|
||||
stats: {
|
||||
remaining: "Kalan",
|
||||
dateValue: (d: string) => `${d} değeri`,
|
||||
currentValue: "Güncel değer",
|
||||
accumulated: "Biriken",
|
||||
remainingNeed: "Kalan ihtiyaç",
|
||||
estimatedClose: "Tahmini kapanış",
|
||||
noPrice: "Güncel fiyat yok",
|
||||
closed: (d: string) => `Kapandı · ${d}`,
|
||||
priceAt: (price: string, date: string) => `Fiyat ${price} (${date})`,
|
||||
currentPrice: (debt: boolean, price: string, date: string) =>
|
||||
`Güncel ${debt ? "satış" : "alış"} fiyatı ${price} · ${date}`,
|
||||
},
|
||||
contribute: "Birikim ekle",
|
||||
contributionTitle: "Birikim ekle",
|
||||
fromAccount: "Kaynak hesap",
|
||||
contributionDescription: (label: string, name: string) => `Birikim: ${label} borcu (${name})`,
|
||||
pay: "Ödeme gerçekleşti",
|
||||
payTitle: "Ödeme gerçekleşti",
|
||||
methods: { asset: "Aynı varlıkla", cash: "TL karşılığıyla" },
|
||||
methodHints: {
|
||||
asset: "Varlığın kendisiyle ödendi (ör. kuyumcudan alınan altın verildi).",
|
||||
cash: "Varlığın TL karşılığı ödendi; hesaptan çıkış işlemi oluşturulur.",
|
||||
},
|
||||
receiveHints: {
|
||||
asset: "Varlığın kendisi teslim alındı.",
|
||||
cash: "Varlığın TL karşılığı hesaba tahsil edildi.",
|
||||
},
|
||||
payFields: {
|
||||
quantity: "Ödenen miktar",
|
||||
quantityHint: "Boş bırakılırsa tutar güncel fiyata bölünerek hesaplanır.",
|
||||
amount: "Tutar (TL)",
|
||||
assetAmount: "Varlığı almak için harcanan (TL, isteğe bağlı)",
|
||||
account: "Hesap",
|
||||
},
|
||||
paymentDescription: (debt: boolean, label: string, name: string) =>
|
||||
debt ? `Aynî borç ödemesi: ${label} (${name})` : `Aynî alacak tahsilatı: ${label} (${name})`,
|
||||
payments: "Ödemeler",
|
||||
paymentLine: (method: string, qty: string, amount: string | null) =>
|
||||
amount ? `${method} · ${qty} · ${amount}` : `${method} · ${qty}`,
|
||||
delete: "Kaydı sil",
|
||||
deleteTitle: "Aynî kayıt silinsin mi?",
|
||||
deleteDescription:
|
||||
"Kayıt listeden kaldırılır. Oluşturulmuş para hareketleri (birikim, ödeme) işlemlerde kalır.",
|
||||
prices: "Fiyat tablosu",
|
||||
toast: {
|
||||
saved: "Aynî kayıt kaydedildi.",
|
||||
contributed: "Birikim eklendi.",
|
||||
paid: "Ödeme kaydedildi.",
|
||||
closed: "Ödeme kaydedildi; kayıt kapandı.",
|
||||
deleted: "Aynî kayıt silindi.",
|
||||
},
|
||||
errors: {
|
||||
noPrice: "Bu tarih için fiyat yok; birim fiyatı girin veya fiyat tablosunu güncelleyin.",
|
||||
noSavings: "Önce kayda bir birikim hesabı seçin.",
|
||||
closed: "Kayıt kapanmış.",
|
||||
quantityBelowPaid: "Miktar ödenmiş miktardan az olamaz.",
|
||||
receivablePlan: "Birikim planı yalnızca borçlar içindir.",
|
||||
noCurrentPrice: "Güncel fiyat yok; ödenen miktarı girin.",
|
||||
sameAccount: "Kaynak hesap birikim hesabıyla aynı olamaz.",
|
||||
},
|
||||
validation: {
|
||||
quantity: "Geçerli bir miktar girin (en fazla 3 ondalık)",
|
||||
price: "Alış veya satış fiyatı girin",
|
||||
amount: "Tutar girin",
|
||||
account: "Hesap seçin",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
"Altın ve döviz fiyatları (elle). Varlıklar ve aynî alacaklar alış, aynî borçlar satış fiyatıyla değerlenir.",
|
||||
add: "Fiyat gir",
|
||||
asset: "Varlık",
|
||||
date: "Tarih",
|
||||
buy: "Alış (TL)",
|
||||
sell: "Satış (TL)",
|
||||
sellHint: "Boş bırakılırsa alış fiyatı kullanılır (tek fiyat).",
|
||||
noPrice: "Fiyat yok",
|
||||
history: "Son fiyatlar",
|
||||
perUnit: (unit: string) => `/ ${unit}`,
|
||||
saved: "Fiyat kaydedildi.",
|
||||
},
|
||||
card: {
|
||||
title: "Kart özeti",
|
||||
debt: "Toplam borç",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Fiyat tablosu (Bölüm 6.6): varlık × tarih × alış/satış (birim başına kuruş). İlk aşamada elle
|
||||
* giriş; otomatik kaynaklar Faz 17'de `source` ile eklenir. Gün başına tek kayıt (yeniden girişte
|
||||
* güncellenir).
|
||||
*/
|
||||
const assetPriceSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
buyMinor: { type: Number, required: true, min: 0 },
|
||||
sellMinor: { type: Number, required: true, min: 0 },
|
||||
source: { type: String, enum: ["manual"], default: "manual" },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "assetPrices" },
|
||||
);
|
||||
|
||||
assetPriceSchema.index({ householdId: 1, assetKey: 1, date: -1 }, { unique: true });
|
||||
|
||||
export type AssetPrice = InferSchemaType<typeof assetPriceSchema>;
|
||||
|
||||
export const AssetPriceModel: Model<AssetPrice> =
|
||||
(models.AssetPrice as Model<AssetPrice> | undefined) ??
|
||||
model<AssetPrice>("AssetPrice", assetPriceSchema);
|
||||
@@ -30,6 +30,9 @@ export const AUDIT_ACTIONS = [
|
||||
"recurring.delete",
|
||||
"contact.archive",
|
||||
"contact.unarchive",
|
||||
"inkind.payment",
|
||||
"inkind.close",
|
||||
"inkind.delete",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { ASSET_KEYS } from "@/shared/assets";
|
||||
|
||||
/**
|
||||
* Aynî borç/alacak (Bölüm 6.5): bir cariyle altın, ziynet veya döviz cinsinden borç.
|
||||
* Borç tarihi fiyatı ve TL karşılığı not olarak saklanır; güncel değer fiyat tablosundan.
|
||||
* Kapanış yalnızca "Ödeme gerçekleşti" kayıtlarıyla (kısmi ödeme miktarı düşürür).
|
||||
* Birikim planı: katkılar seçilen birikim hesabına transferdir (`transactions.inKindDebtId`).
|
||||
*/
|
||||
const paymentSchema = new Schema(
|
||||
{
|
||||
date: { type: String, required: true },
|
||||
/** asset: aynı varlıkla · cash: TL karşılığıyla. */
|
||||
method: { type: String, enum: ["asset", "cash"], required: true },
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
/** Ödemede harcanan / tahsil edilen TL (aynı varlıkla ödemede varlığı almak için harcanan). */
|
||||
amountMinor: { type: Number, default: 0, min: 0 },
|
||||
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ _id: true, timestamps: { createdAt: true, updatedAt: false } },
|
||||
);
|
||||
|
||||
const inKindDebtSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
contactId: { type: Schema.Types.ObjectId, required: true },
|
||||
/** debt: ben borçluyum · receivable: bana borçlu. */
|
||||
direction: { type: String, enum: ["debt", "receivable"], required: true },
|
||||
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||
/** Binde bir (5 gr → 5000). */
|
||||
quantity: { type: Number, required: true, min: 1 },
|
||||
date: { type: String, required: true },
|
||||
unitPriceMinor: { type: Number, default: null },
|
||||
valueMinor: { type: Number, default: null },
|
||||
note: { type: String, default: "", maxlength: 500 },
|
||||
savingsAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||
monthlyPlanMinor: { type: Number, default: null },
|
||||
payments: { type: [paymentSchema], default: [] },
|
||||
closedAt: { type: String, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "inKindDebts" },
|
||||
);
|
||||
|
||||
inKindDebtSchema.index({ householdId: 1, contactId: 1, deletedAt: 1 });
|
||||
inKindDebtSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type InKindDebt = InferSchemaType<typeof inKindDebtSchema>;
|
||||
|
||||
export const InKindDebtModel: Model<InKindDebt> =
|
||||
(models.InKindDebt as Model<InKindDebt> | undefined) ??
|
||||
model<InKindDebt>("InKindDebt", inKindDebtSchema);
|
||||
@@ -58,6 +58,12 @@ const transactionSchema = new Schema(
|
||||
/** Cari transferi: karşı taraf kişi (hesabın biri boş). */
|
||||
contactId: { type: Schema.Types.ObjectId, default: null },
|
||||
contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null },
|
||||
/**
|
||||
* Aynî borca bağlı para hareketi: birikim katkısı (hesap → birikim hesabı) veya ödeme.
|
||||
* `contactId` görünürlük için doludur ama `contactKind` boştur: cari TL bakiyesine girmez.
|
||||
*/
|
||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
||||
/**
|
||||
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
||||
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAssetQuantity,
|
||||
inKindProgress,
|
||||
quantityForAmount,
|
||||
valuationPrice,
|
||||
valueOf,
|
||||
} from "./assets";
|
||||
|
||||
describe("varlık hesapları", () => {
|
||||
it("değer ve miktar karşılığı (binde bir miktar, kuruş fiyat)", () => {
|
||||
// 5 gr × 3.250,50 TL = 16.252,50 TL
|
||||
expect(valueOf(5_000, 3_250_50)).toBe(16_252_50);
|
||||
expect(valueOf(1_500, 100_00)).toBe(150_00);
|
||||
expect(quantityForAmount(16_252_50, 3_250_50)).toBe(5_000);
|
||||
expect(quantityForAmount(100, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("biçim", () => {
|
||||
expect(formatAssetQuantity("gram_gold", 5_000)).toBe("5 gr Gram altın (24 ayar)");
|
||||
expect(formatAssetQuantity("quarter_gold", 2_000)).toBe("2 adet Çeyrek altın");
|
||||
expect(formatAssetQuantity("usd", 1_500_000)).toBe("1.500 USD");
|
||||
expect(formatAssetQuantity("bracelet_22k", 12_345)).toBe("12,345 gr 22 ayar bilezik");
|
||||
});
|
||||
|
||||
it("değerleme fiyatı: borç satış, alacak alış", () => {
|
||||
const p = { buyMinor: 3_200_00, sellMinor: 3_300_00 };
|
||||
expect(valuationPrice("debt", p)).toBe(3_300_00);
|
||||
expect(valuationPrice("receivable", p)).toBe(3_200_00);
|
||||
expect(valuationPrice("debt", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aynî borç ilerlemesi", () => {
|
||||
const base = {
|
||||
quantity: 5_000,
|
||||
paidQuantity: 0,
|
||||
unitPriceMinor: 3_000_00,
|
||||
contributedMinor: 6_000_00,
|
||||
spentMinor: 0,
|
||||
monthlyPlanMinor: 2_000_00,
|
||||
today: "2026-11-05",
|
||||
};
|
||||
|
||||
it("kalan ihtiyaç ve tahmini kapanış ayı", () => {
|
||||
// 5 gr × 3.000 = 15.000; biriken 6.000 → 9.000 kaldı; ayda 2.000 → 5 ay → 2027-04
|
||||
expect(inKindProgress(base)).toEqual({
|
||||
remainingQuantity: 5_000,
|
||||
currentValueMinor: 15_000_00,
|
||||
accumulatedMinor: 6_000_00,
|
||||
remainingNeedMinor: 9_000_00,
|
||||
estimatedCloseMonth: "2027-04",
|
||||
});
|
||||
});
|
||||
|
||||
it("kısmi ödeme, birikim yeterli, fiyat yok, plan yok", () => {
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).remainingNeedMinor).toBe(0);
|
||||
expect(inKindProgress({ ...base, paidQuantity: 3_000 }).estimatedCloseMonth).toBe("2026-11");
|
||||
expect(inKindProgress({ ...base, unitPriceMinor: null }).remainingNeedMinor).toBeNull();
|
||||
expect(inKindProgress({ ...base, monthlyPlanMinor: null }).estimatedCloseMonth).toBeNull();
|
||||
expect(inKindProgress({ ...base, paidQuantity: 5_000 }).remainingQuantity).toBe(0);
|
||||
expect(inKindProgress({ ...base, spentMinor: 9_000_00 }).accumulatedMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { QUANTITY_SCALE } from "./quantity";
|
||||
import { addMonths } from "./recurrence";
|
||||
|
||||
/**
|
||||
* Varlık türleri (Bölüm 6.5–6.6): altın, ziynet, gümüş ve döviz. Miktarlar binde bir tamsayı
|
||||
* (5 gr → 5000, 2 adet çeyrek → 2000). Fiyatlar birim başına kuruş.
|
||||
*
|
||||
* Fiyat terimleri kuyumcu/döviz bürosu tarafından: "alış" = kurumun sizden aldığı (düşük),
|
||||
* "satış" = kurumun size sattığı (yüksek). Faz 0 kararı: varlıklar ve aynî alacaklar ALIŞ,
|
||||
* aynî borçlar SATIŞ fiyatıyla değerlenir.
|
||||
*/
|
||||
|
||||
export const ASSET_KEYS = [
|
||||
"gram_gold",
|
||||
"quarter_gold",
|
||||
"half_gold",
|
||||
"full_gold",
|
||||
"republic_gold",
|
||||
"bracelet_22k",
|
||||
"silver_gram",
|
||||
"usd",
|
||||
"eur",
|
||||
"gbp",
|
||||
] as const;
|
||||
export type AssetKey = (typeof ASSET_KEYS)[number];
|
||||
|
||||
export type AssetUnit = "gr" | "adet" | "birim";
|
||||
|
||||
export const ASSETS: Record<
|
||||
AssetKey,
|
||||
{ label: string; unit: AssetUnit; group: "gold" | "silver" | "fx" }
|
||||
> = {
|
||||
gram_gold: { label: "Gram altın (24 ayar)", unit: "gr", group: "gold" },
|
||||
quarter_gold: { label: "Çeyrek altın", unit: "adet", group: "gold" },
|
||||
half_gold: { label: "Yarım altın", unit: "adet", group: "gold" },
|
||||
full_gold: { label: "Tam altın", unit: "adet", group: "gold" },
|
||||
republic_gold: { label: "Cumhuriyet (Ata) altını", unit: "adet", group: "gold" },
|
||||
bracelet_22k: { label: "22 ayar bilezik", unit: "gr", group: "gold" },
|
||||
silver_gram: { label: "Gümüş (gram)", unit: "gr", group: "silver" },
|
||||
usd: { label: "ABD doları", unit: "birim", group: "fx" },
|
||||
eur: { label: "Euro", unit: "birim", group: "fx" },
|
||||
gbp: { label: "İngiliz sterlini", unit: "birim", group: "fx" },
|
||||
};
|
||||
|
||||
const FX_SYMBOL: Partial<Record<AssetKey, string>> = { usd: "USD", eur: "EUR", gbp: "GBP" };
|
||||
|
||||
/** "5 gr Gram altın (24 ayar)", "2 adet Çeyrek altın", "1.500 USD". */
|
||||
export function formatAssetQuantity(key: AssetKey, quantity: number): string {
|
||||
const n = (quantity / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3 });
|
||||
const fx = FX_SYMBOL[key];
|
||||
if (fx) return `${n} ${fx}`;
|
||||
const a = ASSETS[key];
|
||||
return `${n} ${a.unit} ${a.label}`;
|
||||
}
|
||||
|
||||
/** Miktar × birim fiyat (kuruş, yuvarlanır). */
|
||||
export function valueOf(quantity: number, unitPriceMinor: number): number {
|
||||
return Math.round((quantity * unitPriceMinor) / QUANTITY_SCALE);
|
||||
}
|
||||
|
||||
/** TL tutarın karşılığı olan miktar (binde bir, yuvarlanır). */
|
||||
export function quantityForAmount(amountMinor: number, unitPriceMinor: number): number {
|
||||
if (unitPriceMinor <= 0) return 0;
|
||||
return Math.round((amountMinor * QUANTITY_SCALE) / unitPriceMinor);
|
||||
}
|
||||
|
||||
export type InKindDirection = "debt" | "receivable";
|
||||
|
||||
/** Değerlemede kullanılacak fiyat: borç → satış, alacak → alış. */
|
||||
export function valuationPrice(
|
||||
direction: InKindDirection,
|
||||
price: { buyMinor: number; sellMinor: number } | null,
|
||||
): number | null {
|
||||
if (!price) return null;
|
||||
return direction === "debt" ? price.sellMinor : price.buyMinor;
|
||||
}
|
||||
|
||||
export type InKindProgress = {
|
||||
remainingQuantity: number;
|
||||
/** Kalan miktarın güncel TL karşılığı (fiyat yoksa null). */
|
||||
currentValueMinor: number | null;
|
||||
/** Birikim planı: biriken − ödemelerde kullanılan. */
|
||||
accumulatedMinor: number;
|
||||
remainingNeedMinor: number | null;
|
||||
/** Aylık birikimle kalan ihtiyaç kaç ayda kapanır; "YYYY-MM" (plan yoksa null). */
|
||||
estimatedCloseMonth: string | null;
|
||||
};
|
||||
|
||||
export function inKindProgress(input: {
|
||||
quantity: number;
|
||||
paidQuantity: number;
|
||||
unitPriceMinor: number | null;
|
||||
contributedMinor: number;
|
||||
spentMinor: number;
|
||||
monthlyPlanMinor: number | null;
|
||||
today: string;
|
||||
}): InKindProgress {
|
||||
const remainingQuantity = Math.max(0, input.quantity - input.paidQuantity);
|
||||
const currentValueMinor =
|
||||
input.unitPriceMinor === null ? null : valueOf(remainingQuantity, input.unitPriceMinor);
|
||||
const accumulatedMinor = Math.max(0, input.contributedMinor - input.spentMinor);
|
||||
const remainingNeedMinor =
|
||||
currentValueMinor === null ? null : Math.max(0, currentValueMinor - accumulatedMinor);
|
||||
let estimatedCloseMonth: string | null = null;
|
||||
if (remainingQuantity === 0) estimatedCloseMonth = null;
|
||||
else if (remainingNeedMinor === 0) estimatedCloseMonth = input.today.slice(0, 7);
|
||||
else if (remainingNeedMinor !== null && input.monthlyPlanMinor && input.monthlyPlanMinor > 0) {
|
||||
const months = Math.ceil(remainingNeedMinor / input.monthlyPlanMinor);
|
||||
estimatedCloseMonth = addMonths(`${input.today.slice(0, 7)}-01`, months).slice(0, 7);
|
||||
}
|
||||
return {
|
||||
remainingQuantity,
|
||||
currentValueMinor,
|
||||
accumulatedMinor,
|
||||
remainingNeedMinor,
|
||||
estimatedCloseMonth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { ASSET_KEYS } from "../assets";
|
||||
import { parseMoney } from "../money";
|
||||
import { parseQuantity } from "../quantity";
|
||||
|
||||
const v = tr.validation;
|
||||
const k = tr.inKind.validation;
|
||||
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
|
||||
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
|
||||
|
||||
/** Boş → null; dolu → pozitif kuruş. */
|
||||
const optionalMoney = z.preprocess(
|
||||
emptyToNull,
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const minor = parseMoney(s);
|
||||
if (minor === null || minor < 0) {
|
||||
ctx.addIssue({ code: "custom", message: v.money });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
})
|
||||
.nullable(),
|
||||
);
|
||||
|
||||
const quantity = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const q = parseQuantity(s);
|
||||
if (q === null || q <= 0) {
|
||||
ctx.addIssue({ code: "custom", message: k.quantity });
|
||||
return z.NEVER;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
/** Fiyat girişi: satış boşsa alışla aynı (tek fiyat girilirse ikisine de yazılır — Faz 0 kararı). */
|
||||
export const assetPriceFormSchema = z
|
||||
.object({
|
||||
assetKey: z.enum(ASSET_KEYS),
|
||||
date: ymd,
|
||||
buy: optionalMoney,
|
||||
sell: optionalMoney,
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.buy === null && x.sell === null)
|
||||
ctx.addIssue({ code: "custom", path: ["buy"], message: k.price });
|
||||
})
|
||||
.transform((x) => ({
|
||||
assetKey: x.assetKey,
|
||||
date: x.date,
|
||||
buyMinor: (x.buy ?? x.sell)!,
|
||||
sellMinor: (x.sell ?? x.buy)!,
|
||||
}));
|
||||
export type AssetPriceForm = z.output<typeof assetPriceFormSchema>;
|
||||
|
||||
/** Aynî borç/alacak kaydı. Birim fiyat boşsa fiyat tablosundan (o tarihteki son fiyat) alınır. */
|
||||
export const inKindFormSchema = z.object({
|
||||
direction: z.enum(["debt", "receivable"]),
|
||||
assetKey: z.enum(ASSET_KEYS),
|
||||
quantity,
|
||||
date: ymd,
|
||||
unitPrice: optionalMoney,
|
||||
note: z.string().trim().max(500).default(""),
|
||||
savingsAccountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||
monthlyPlan: optionalMoney,
|
||||
});
|
||||
export type InKindForm = z.output<typeof inKindFormSchema>;
|
||||
|
||||
/** Birikim katkısı: hesaptan borcun birikim hesabına transfer. */
|
||||
export const contributionSchema = z.object({
|
||||
fromAccountId: objectId,
|
||||
amount: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((s, ctx) => {
|
||||
const minor = parseMoney(s);
|
||||
if (minor === null || minor <= 0) {
|
||||
ctx.addIssue({ code: "custom", message: tr.transactions.validation.amountPositive });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
}),
|
||||
date: ymd,
|
||||
});
|
||||
export type ContributionForm = z.output<typeof contributionSchema>;
|
||||
|
||||
/**
|
||||
* "Ödeme gerçekleşti". Aynı varlıkla: miktar zorunlu, TL (varlığı almak için harcanan) isteğe
|
||||
* bağlı. TL karşılığıyla: tutar ve hesap zorunlu; miktar boşsa güncel fiyattan hesaplanır.
|
||||
*/
|
||||
export const paymentFormSchema = z
|
||||
.object({
|
||||
method: z.enum(["asset", "cash"]),
|
||||
date: ymd,
|
||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||
amount: optionalMoney,
|
||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.method === "asset" && x.quantity === null)
|
||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||
if (x.method === "cash") {
|
||||
if (!x.amount) ctx.addIssue({ code: "custom", path: ["amount"], message: k.amount });
|
||||
if (!x.accountId) ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
|
||||
}
|
||||
if (x.amount && !x.accountId)
|
||||
ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account });
|
||||
});
|
||||
export type PaymentForm = z.output<typeof paymentFormSchema>;
|
||||
Reference in New Issue
Block a user