feat: Faz 10b - gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -6,6 +6,26 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 10b: gelir planı ve günlük plan
|
||||||
|
|
||||||
|
- Gelir planı (`/butce` altında): gerçekleşen gelirler, henüz gerçekleşmemiş planlı (düzenli) gelirler ve elle eklenen beklenen gelirler (`budgets.incomeItems`, ör. prim); beklenen toplam. "Plana göre elde kalacak" (beklenen gelir − bütçe) ve "Bu tempoda elde kalacak" (beklenen gelir − ay sonu tahmini gider).
|
||||||
|
- Günlük plan (dashboard): ayın bütçesi günlere yayılır; tarihe bağlı giderler (düzenli işlemler, taksitler) kendi günlerine, kalan değişken bütçe günlere eşit veya hane ayarına göre önceki ayın günlük örüntüsüyle (kuruşu kuruşuna toplam korunur). "Bugün ayın %X'i geçti, bütçenin %Y'si harcandı", bugün için harcanabilir tutar (önceki günlerden kalan/aşan dahil), bugüne kadar planlanan ve harcanan, ay sonunda elde kalacak.
|
||||||
|
- Kümülatif planlanan / gerçekleşen gider grafiği, beklenen gelir referans çizgisi ve gün tablosu; sonraki ayın beklenen geliri ile bütçesi karşılaştırması.
|
||||||
|
- Dashboard özet kartları: "Bu ay gelir", "Bu ay gider" ve "Kalan" gerçek veriyle (bütçe ayına göre).
|
||||||
|
- Testler: dağıtım ve kuruş yuvarlama, örüntü uyarlama, günlük plan ve bugünkü pay, taban hızlı tahmin (birim); gelir planı, gizli hesap ayarı, elle kalemler, yetki, günlük plan tutarlılığı ve örüntü modu (entegrasyon).
|
||||||
|
|
||||||
|
### Değişti — Faz 10b
|
||||||
|
|
||||||
|
- Ay sonu tahmini: değişken giderin günlük hızı, bu ayın hızı ile önceki dönemin değişken harcama hızının karışımı (ay ilerledikçe bu ayın ağırlığı artar); ay başındaki tek bir büyük harcama artık tüm aya yayılmıyor. Geçmişi olmayan hanede yalnızca bu ayın hızı.
|
||||||
|
|
||||||
|
### Düzeltildi — Faz 10b
|
||||||
|
|
||||||
|
- Bütçede "sabit gider" (düzenli işlem / taksit) ayrımı her işlemi sabit sayıyordu (`installment` boşken alan yolu eksik değer verir ve `null`'a eşit sayılmaz); ay sonu tahmini değişken gideri hiç yansıtmıyordu. `$ifNull` ile düzeltildi, regresyon testi eklendi.
|
||||||
|
|
||||||
### Eklendi — Faz 10a: aylık bütçe
|
### Eklendi — Faz 10a: aylık bütçe
|
||||||
|
|
||||||
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık).
|
||||||
@@ -298,7 +318,8 @@ Faz 1: Framework, klasör yapısı ve altyapı.
|
|||||||
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri.
|
||||||
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
- `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`.
|
||||||
|
|
||||||
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...HEAD
|
[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.10.0...HEAD
|
||||||
|
[0.10.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.9.0...v0.10.0
|
||||||
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
[0.9.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.8.0...v0.9.0
|
||||||
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
[0.8.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.7.0...v0.8.0
|
||||||
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
[0.7.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.6.0...v0.7.0
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
- **Faz 7 kararları (2026-11-05):** 7a planlı/düzenli işlemler, 7b kart/KMH dönemleri ve taksitler; `v0.7.0` 7b sonunda. Taksitli harcamada her taksit ayrı işlem ("Hepsiburada 4/9"); vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler kullanılabilir limitten düşer ama güncel borçta görünmez. Bitiş tarihi olmayan düzenli işlemler 12 ay ileriye üretilir (ufuk okuma sırasında ilerletilir). Asgari ödeme varsayılanı: limit 50.000 TL altı %20, üstü %40; kart bazında değiştirilebilir.
|
||||||
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
- **Faz 8 kararları (2026-11-05):** 8a cari hesaplar, 8b aynî borç/alacak; `v0.8.0` 8b sonunda. Varlık türleri ve elle fiyat tablosu (Bölüm 6.6) 8b'ye çekilir, Faz 9 yeniden kullanır. Cariler hane ortaktır; "yalnızca ben" işaretli cari yalnızca sahibine görünür. Vade hatırlatması uygulama içi uyarıdır (dashboard + cari sayfası); push/e-posta Faz 16'da değerlendirilir.
|
||||||
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
- **Faz 9 kararları (2026-11-05):** 9a varlık lotları ve kâr/zarar, 9b dönemsel rapor ve net varlık; `v0.9.0` 9b sonunda. Varlıklar hane ortaktır; "yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür. Varlık alım/satışı hesaba bağlıysa transfer olarak kaydedilir (gelir/gider raporlarına girmez). Aynî kayıt "aynı varlıkla" kapanırken isteğe bağlı olarak varlıktan düşülür / varlığa eklenir.
|
||||||
|
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { BudgetView } from "@/features/budgets/components/budget-view";
|
import { BudgetView } from "@/features/budgets/components/budget-view";
|
||||||
|
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
import { getBudget } from "@/features/budgets/service";
|
import { getBudget } from "@/features/budgets/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -15,10 +17,27 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
|||||||
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
|
const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
const income = await getIncomePlan(user.id, household.id, data.month);
|
||||||
|
if (!income) notFound();
|
||||||
|
const monthLabel = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
}).format(new Date(`${data.month}-15T12:00:00+03:00`));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||||
<BudgetView key={data.month} data={data} />
|
<BudgetView key={data.month} data={data} />
|
||||||
|
<IncomePlanSection
|
||||||
|
key={`income-${data.month}`}
|
||||||
|
month={data.month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
income={income}
|
||||||
|
limitMinor={data.totals.limitMinor}
|
||||||
|
forecastMinor={data.totals.forecastMinor}
|
||||||
|
hasBudget={data.hasBudget}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { Suspense } from "react";
|
|||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||||
|
import { DailyPlanCard } from "@/features/budgets/components/daily-plan-card";
|
||||||
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner";
|
||||||
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||||
import { getNetWorth } from "@/features/net-worth/service";
|
import { getNetWorth } from "@/features/net-worth/service";
|
||||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||||
@@ -23,15 +26,25 @@ type SummaryCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Özet kartları. Bakiye, kart borcu ve net varlık gerçek (Faz 9); bu ayın gelir/gider/kalan
|
* Özet kartları (içinde bulunulan bütçe ayı): gerçekleşen gelir, gider ve farkı; hesap bakiyesi,
|
||||||
* kartları Faz 14'te dolar.
|
* kart borcu ve net varlık.
|
||||||
*/
|
*/
|
||||||
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
async function getSummary(userId: string, householdId: string): Promise<SummaryCard[]> {
|
||||||
const nw = await getNetWorth(userId, householdId);
|
const [nw, budget] = await Promise.all([
|
||||||
|
getNetWorth(userId, householdId),
|
||||||
|
getBudget(userId, householdId),
|
||||||
|
]);
|
||||||
|
const income = budget ? await getIncomePlan(userId, householdId, budget.month) : null;
|
||||||
|
const inc = income?.actualMinor ?? null;
|
||||||
|
const exp = budget?.totals.actualMinor ?? null;
|
||||||
return [
|
return [
|
||||||
{ key: "monthIncome", tone: "text-income", amountMinor: null },
|
{ key: "monthIncome", tone: "text-income", amountMinor: inc, href: "/butce" },
|
||||||
{ key: "monthExpense", tone: "text-expense", amountMinor: null },
|
{ key: "monthExpense", tone: "text-expense", amountMinor: exp, href: "/butce" },
|
||||||
{ key: "monthRemaining", tone: "text-budget", amountMinor: null },
|
{
|
||||||
|
key: "monthRemaining",
|
||||||
|
tone: "text-budget",
|
||||||
|
amountMinor: inc !== null && exp !== null ? inc - exp : null,
|
||||||
|
},
|
||||||
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
{ key: "totalBalance", amountMinor: nw?.accountsMinor ?? null, href: "/hesaplar" },
|
||||||
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
{ key: "cardDebt", tone: "text-expense", amountMinor: nw?.cardDebtMinor ?? null },
|
||||||
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
{ key: "netWorth", amountMinor: nw?.totalMinor ?? null, href: "/birikim" },
|
||||||
@@ -89,6 +102,9 @@ export default async function DashboardPage() {
|
|||||||
<Suspense fallback={<SummaryCardsSkeleton />}>
|
<Suspense fallback={<SummaryCardsSkeleton />}>
|
||||||
<SummaryCards userId={user.id} householdId={household.id} />
|
<SummaryCards userId={user.id} householdId={household.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 10.1;
|
export const CURRENT_PHASE = 10.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { z } from "zod";
|
|||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, type ActionResult } from "@/lib/action-result";
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
import { getHouseholdActionContext } from "@/lib/dal";
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
import { budgetLimitsSchema, budgetMonthSchema } from "@/shared/schemas/budget";
|
import { budgetLimitsSchema, budgetMonthSchema, incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { saveIncomeItems } from "./plan";
|
||||||
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
function fieldErrors(error: z.ZodError) {
|
function fieldErrors(error: z.ZodError) {
|
||||||
@@ -42,3 +43,7 @@ export async function copyPreviousBudgetAction(month: unknown) {
|
|||||||
export async function budgetFromActualsAction(month: unknown) {
|
export async function budgetFromActualsAction(month: unknown) {
|
||||||
return run(budgetMonthSchema, month, budgetFromActuals);
|
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItemsAction(input: unknown) {
|
||||||
|
return run(incomeItemsSchema, input, saveIncomeItems);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getDailyPlan } from "../plan";
|
||||||
|
import { DailyPlanView } from "./daily-plan-view";
|
||||||
|
|
||||||
|
/** Dashboard günlük plan kartı (sunucu): veriyi yükler, görünümü istemciye verir. */
|
||||||
|
export async function DailyPlanCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
}) {
|
||||||
|
const data = await getDailyPlan(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
return <DailyPlanView data={data} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import type { DailyPlanView as Data } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.daily;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const dayFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Istanbul",
|
||||||
|
});
|
||||||
|
const compact = new Intl.NumberFormat("tr-TR", { notation: "compact", maximumFractionDigits: 1 });
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
planned: { label: t.cumPlanned, color: "var(--budget)" },
|
||||||
|
actual: { label: t.cumActual, color: "var(--expense)" },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{hint ? <p className="text-[11px] leading-tight text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard: ayın günlük planı, kümülatif grafik ve gelirle karşılaştırma. */
|
||||||
|
export function DailyPlanView({ data }: { data: Data }) {
|
||||||
|
const p = data.plan;
|
||||||
|
if (!data.hasBudget) {
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.noBudget}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chart = p.days.map((day, i) => ({
|
||||||
|
date: day.date,
|
||||||
|
label: dayFmt.format(new Date(`${day.date}T12:00:00+03:00`)),
|
||||||
|
planned: day.cumPlannedMinor / 100,
|
||||||
|
// Gerçekleşen yalnızca bugüne kadar çizilir.
|
||||||
|
actual: p.todayIndex === -1 || i <= p.todayIndex ? day.cumActualMinor / 100 : null,
|
||||||
|
}));
|
||||||
|
const income = data.income.expectedMinor / 100;
|
||||||
|
const nextDiff =
|
||||||
|
data.next.budgetMinor === null ? null : data.next.expectedIncomeMinor - data.next.budgetMinor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{p.spentPct !== null ? t.headline(p.monthPct, p.spentPct) : null}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t.today}
|
||||||
|
value={signed(p.todayAllowanceMinor)}
|
||||||
|
tone={p.todayAllowanceMinor < 0 ? "text-expense" : "text-budget"}
|
||||||
|
hint={t.todayHint}
|
||||||
|
/>
|
||||||
|
<Stat label={t.plannedToDate} value={formatMoney(p.plannedToDateMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.actualToDate}
|
||||||
|
value={formatMoney(p.actualToDateMinor)}
|
||||||
|
tone={p.actualToDateMinor > p.plannedToDateMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(data.projectedLeftMinor)}
|
||||||
|
tone={data.projectedLeftMinor < 0 ? "text-expense" : "text-income"}
|
||||||
|
hint={tr.budget.income.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="aspect-auto h-56 w-full"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart data={chart} margin={{ left: 4, right: 12, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
/>
|
||||||
|
{income > 0 ? (
|
||||||
|
<ReferenceLine
|
||||||
|
y={income}
|
||||||
|
stroke="var(--income)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{
|
||||||
|
value: `${t.income} ${compact.format(income)}`,
|
||||||
|
position: "insideTopLeft",
|
||||||
|
fill: "var(--muted-foreground)",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const date = payload?.[0]?.payload?.date as string | undefined;
|
||||||
|
return date ? d(date) : "";
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{config[name as keyof typeof config]?.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
|
{formatMoney(Math.round(Number(value) * 100))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
dataKey="planned"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-planned)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="actual"
|
||||||
|
type="linear"
|
||||||
|
stroke="var(--color-actual)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t.modes[data.mode]} {p.fixedMinor ? t.fixedNote(formatMoney(p.fixedMinor)) : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3 text-sm">
|
||||||
|
<p className="mb-2 font-medium capitalize">
|
||||||
|
{t.next(monthFmt.format(new Date(`${data.next.month}-15T12:00:00+03:00`)))}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Stat label={t.nextIncome} value={formatMoney(data.next.expectedIncomeMinor)} />
|
||||||
|
<Stat
|
||||||
|
label={t.nextBudget}
|
||||||
|
value={
|
||||||
|
data.next.budgetMinor === null ? t.nextNoBudget : formatMoney(data.next.budgetMinor)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t.nextDiff}
|
||||||
|
value={nextDiff === null ? "—" : signed(nextDiff)}
|
||||||
|
tone={nextDiff !== null && nextDiff < 0 ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">{t.table}</summary>
|
||||||
|
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||||
|
<table className="w-full text-right tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-card text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 text-left font-normal">{t.day}</th>
|
||||||
|
<th className="py-1 font-normal">{t.planned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.actual}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumPlanned}</th>
|
||||||
|
<th className="py-1 font-normal">{t.cumActual}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{p.days.map((day, i) => (
|
||||||
|
<tr key={day.date} className={cn(i === p.todayIndex && "bg-muted font-medium")}>
|
||||||
|
<td className="py-1 text-left">{d(day.date)}</td>
|
||||||
|
<td className="py-1">{formatMoney(day.plannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.actualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{formatMoney(day.cumPlannedMinor)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{p.todayIndex === -1 || i <= p.todayIndex
|
||||||
|
? formatMoney(day.cumActualMinor)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoneyInput } from "@/components/money-input";
|
||||||
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { saveIncomeItemsAction } from "../actions";
|
||||||
|
import type { IncomePlan } from "../plan";
|
||||||
|
|
||||||
|
const t = tr.budget.income;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
hint?: 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>
|
||||||
|
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signed = (minor: number) => `${minor < 0 ? "−" : ""}${formatMoney(Math.abs(minor))}`;
|
||||||
|
|
||||||
|
/** /butce: ayın gelir planı ve gelir − gider karşılaştırması. */
|
||||||
|
export function IncomePlanSection({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
income,
|
||||||
|
limitMinor,
|
||||||
|
forecastMinor,
|
||||||
|
hasBudget,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
income: IncomePlan;
|
||||||
|
limitMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
hasBudget: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const projected = income.expectedMinor - forecastMinor;
|
||||||
|
const planned = income.expectedMinor - limitMinor;
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{t.title}</CardTitle>
|
||||||
|
<CardDescription>{t.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||||
|
<Plus />
|
||||||
|
{t.edit}
|
||||||
|
</Button>
|
||||||
|
) : 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.actual} value={formatMoney(income.actualMinor)} tone="text-income" />
|
||||||
|
<Stat label={t.pending} value={formatMoney(income.pendingMinor)} />
|
||||||
|
<Stat label={t.manual} value={formatMoney(income.manualMinor)} />
|
||||||
|
<Stat label={t.expected} value={formatMoney(income.expectedMinor)} tone="text-income" />
|
||||||
|
{hasBudget ? (
|
||||||
|
<Stat
|
||||||
|
label={t.plannedLeft}
|
||||||
|
value={signed(planned)}
|
||||||
|
tone={planned < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.plannedLeftHint}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stat
|
||||||
|
label={t.projectedLeft}
|
||||||
|
value={signed(projected)}
|
||||||
|
tone={projected < 0 ? "text-expense" : undefined}
|
||||||
|
hint={t.projectedLeftHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{income.entries.length || income.manual.length ? (
|
||||||
|
<ul className="divide-y rounded-lg border text-sm">
|
||||||
|
{income.entries.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(e.date)} · {e.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant={e.status === "posted" ? "secondary" : "outline"}>
|
||||||
|
{e.status === "posted" ? t.received : t.planned}
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(e.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{income.manual.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="min-w-0 truncate">{m.name}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variant="outline">{t.manual}</Badge>
|
||||||
|
<span className="tabular-nums">{formatMoney(m.amountMinor)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{editing ? (
|
||||||
|
<ManualDialog
|
||||||
|
month={month}
|
||||||
|
monthLabel={monthLabel}
|
||||||
|
items={income.manual}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualDialog({
|
||||||
|
month,
|
||||||
|
monthLabel,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
monthLabel: string;
|
||||||
|
items: IncomePlan["manual"];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState(() =>
|
||||||
|
items.length
|
||||||
|
? items.map((i) => ({ key: i.id, name: i.name, amount: formatMoneyInput(i.amountMinor) }))
|
||||||
|
: [{ key: "new-0", name: "", amount: "" }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const r = await saveIncomeItemsAction({
|
||||||
|
month,
|
||||||
|
items: rows
|
||||||
|
.filter((x) => x.name.trim() || x.amount.trim())
|
||||||
|
.map((x) => ({ name: x.name, amount: x.amount })),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
onClose();
|
||||||
|
} else setErrors({ ...(r.fieldErrors ?? {}), root: r.fieldErrors ? "" : r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={t.editTitle(monthLabel)}
|
||||||
|
description={t.editHint}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={onClose}>
|
||||||
|
{tr.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="income-form" disabled={busy}>
|
||||||
|
{tr.common.save}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="income-form" onSubmit={submit} noValidate className="flex flex-col gap-2">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={row.key} className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
value={row.name}
|
||||||
|
placeholder={t.itemName}
|
||||||
|
aria-label={t.itemName}
|
||||||
|
aria-invalid={Boolean(errors[`items.${i}.name`])}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.name`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.name`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<MoneyInput
|
||||||
|
value={row.amount}
|
||||||
|
aria-label={t.itemAmount}
|
||||||
|
onChange={(v) =>
|
||||||
|
setRows((s) => s.map((x, j) => (j === i ? { ...x, amount: v } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors[`items.${i}.amount`] ? (
|
||||||
|
<p className="text-xs text-destructive">{errors[`items.${i}.amount`]}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t.removeItem}
|
||||||
|
onClick={() => setRows((s) => s.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={rows.length >= 30}
|
||||||
|
onClick={() => setRows((s) => [...s, { key: `new-${Date.now()}`, name: "", amount: "" }])}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addItem}
|
||||||
|
</Button>
|
||||||
|
{errors.root ? <p className="text-sm text-destructive">{errors.root}</p> : null}
|
||||||
|
</form>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { incomeItemsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan";
|
||||||
|
import { getBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function income(
|
||||||
|
date: string,
|
||||||
|
amountMinor: number,
|
||||||
|
status: "posted" | "planned",
|
||||||
|
accountId?: Types.ObjectId,
|
||||||
|
) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const acc =
|
||||||
|
accountId ??
|
||||||
|
(await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id;
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "income",
|
||||||
|
status,
|
||||||
|
date: istanbulDayStart(date)!,
|
||||||
|
accountId: acc,
|
||||||
|
amountMinor,
|
||||||
|
description: "Test gelir",
|
||||||
|
recurringRuleId: status === "planned" ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: status === "planned" ? 0 : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gelir planı", () => {
|
||||||
|
it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => {
|
||||||
|
const p0 = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
const { to } = budgetPeriod(month);
|
||||||
|
await income(to, 3_000_00, "planned");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await saveIncomeItems(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
const p = (await getIncomePlan(ayse, cakir, month))!;
|
||||||
|
expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00);
|
||||||
|
expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]);
|
||||||
|
expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00);
|
||||||
|
expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true);
|
||||||
|
// Elle eklenenler bütçe limitlerini bozmaz.
|
||||||
|
expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Gelir",
|
||||||
|
type: "bank",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor;
|
||||||
|
await income(today, 1_000_00, "posted", secret._id);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
// Sahibi kendi gizli hesabını her zaman görür.
|
||||||
|
expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(await getIncomePlan(fatma, cakir, month)).toBeNull();
|
||||||
|
expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("günlük plan", () => {
|
||||||
|
it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => {
|
||||||
|
const v = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
const b = (await getBudget(ayse, cakir))!;
|
||||||
|
expect(v.month).toBe(month);
|
||||||
|
expect(v.hasBudget).toBe(true);
|
||||||
|
const last = v.plan.days.at(-1)!;
|
||||||
|
expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor));
|
||||||
|
expect(v.plan.days).toHaveLength(b.totalDays);
|
||||||
|
expect(v.plan.todayIndex).toBe(b.elapsedDays - 1);
|
||||||
|
// Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni.
|
||||||
|
expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor);
|
||||||
|
expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor);
|
||||||
|
expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor);
|
||||||
|
expect(v.next.month).toBe(shiftMonth(month, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => {
|
||||||
|
const even = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.dailyPlanMode": "previous_month_pattern" } },
|
||||||
|
);
|
||||||
|
const pattern = (await getDailyPlan(ayse, cakir))!;
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } });
|
||||||
|
expect(pattern.mode).toBe("previous_month_pattern");
|
||||||
|
expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor);
|
||||||
|
expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual(
|
||||||
|
even.plan.days.map((d) => d.plannedMinor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
const a = (await getDailyPlan(fatma, annemler))!;
|
||||||
|
expect(a.hasBudget).toBe(false);
|
||||||
|
expect(await getDailyPlan(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } 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 { BudgetModel } from "@/models/budget";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "@/shared/budget";
|
||||||
|
import { dailyPlan, patternWeights, type DailyPlan } from "@/shared/daily-plan";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import type { IncomeItemsForm } from "@/shared/schemas/budget";
|
||||||
|
import { getBudget, settingsOf, type Settings } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gelir planı ve günlük plan (Bölüm 6.9).
|
||||||
|
*
|
||||||
|
* Gelir planı: dönemdeki gerçekleşmiş gelirler + henüz gerçekleşmemiş planlı gelirler (düzenli
|
||||||
|
* işlem tekrarları) + elle eklenen beklenen gelirler. Elle eklenen kalem gelir işlemi olarak
|
||||||
|
* girildiğinde kullanıcı kalemi kaldırır (aksi halde iki kez sayılır).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
function baseMatch(ctx: Ctx, s: Settings, type: "income" | "expense", from: string, to: string) {
|
||||||
|
const m: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
m.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncomeEntry = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
amountMinor: number;
|
||||||
|
status: "posted" | "planned";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IncomePlan = {
|
||||||
|
/** Gerçekleşen gelir. */
|
||||||
|
actualMinor: number;
|
||||||
|
/** Henüz gerçekleşmemiş planlı gelir. */
|
||||||
|
pendingMinor: number;
|
||||||
|
manualMinor: number;
|
||||||
|
/** Beklenen toplam = gerçekleşen + planlı + elle. */
|
||||||
|
expectedMinor: number;
|
||||||
|
/** Planlı/düzenli gelir kalemleri (gerçekleşmiş düzenli gelirler dahil). */
|
||||||
|
entries: IncomeEntry[];
|
||||||
|
manual: { id: string; name: string; amountMinor: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise<IncomePlan> {
|
||||||
|
const { from, to } = budgetPeriod(month, s.startDay);
|
||||||
|
const [docs, budget] = await Promise.all([
|
||||||
|
TransactionModel.find(baseMatch(ctx, s, "income", from, to))
|
||||||
|
.select({
|
||||||
|
date: 1,
|
||||||
|
status: 1,
|
||||||
|
amountMinor: 1,
|
||||||
|
description: 1,
|
||||||
|
payeeName: 1,
|
||||||
|
categoryId: 1,
|
||||||
|
recurringRuleId: 1,
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean(),
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month }).lean(),
|
||||||
|
]);
|
||||||
|
let actual = 0;
|
||||||
|
let pending = 0;
|
||||||
|
const entries: IncomeEntry[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.status === "posted") actual += d.amountMinor;
|
||||||
|
else pending += d.amountMinor;
|
||||||
|
if (d.status === "planned" || d.recurringRuleId) {
|
||||||
|
const cat = d.categoryId ? ctx.categoryMap.get(String(d.categoryId)) : undefined;
|
||||||
|
entries.push({
|
||||||
|
id: String(d._id),
|
||||||
|
date: toIstanbulYmd(d.date),
|
||||||
|
label: d.payeeName || d.description || cat?.name || tr.transactions.types.income,
|
||||||
|
amountMinor: d.amountMinor,
|
||||||
|
status: d.status === "posted" ? "posted" : "planned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manual = (budget?.incomeItems ?? []).map((i) => ({
|
||||||
|
id: String(i._id),
|
||||||
|
name: i.name,
|
||||||
|
amountMinor: i.amountMinor,
|
||||||
|
}));
|
||||||
|
const manualMinor = manual.reduce((a, i) => a + i.amountMinor, 0);
|
||||||
|
return {
|
||||||
|
actualMinor: actual,
|
||||||
|
pendingMinor: pending,
|
||||||
|
manualMinor,
|
||||||
|
expectedMinor: actual + pending + manualMinor,
|
||||||
|
entries,
|
||||||
|
manual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIncomePlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<IncomePlan | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return incomePlan(ctx, await settingsOf(ctx.m.householdId), month);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItems(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: IncomeItemsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month: input.month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
incomeItems: input.items.map((i) => ({ name: i.name, amountMinor: i.amount })),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor, limits: [] },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Günlük plan ---
|
||||||
|
|
||||||
|
/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */
|
||||||
|
async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: baseMatch(ctx, s, "expense", from, to) },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
d: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } },
|
||||||
|
s: "$status",
|
||||||
|
f: {
|
||||||
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sum: { $sum: "$amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { d: string; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>(pipeline);
|
||||||
|
const fixed = new Map<string, number>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const variable = new Map<string, number>();
|
||||||
|
const add = (m: Map<string, number>, k: string, v: number) => m.set(k, (m.get(k) ?? 0) + v);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r._id.f) add(fixed, r._id.d, r.sum);
|
||||||
|
if (r._id.s === "posted") {
|
||||||
|
add(actual, r._id.d, r.sum);
|
||||||
|
if (!r._id.f) add(variable, r._id.d, r.sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fixed, actual, variable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DailyPlanView = {
|
||||||
|
month: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
hasBudget: boolean;
|
||||||
|
plan: DailyPlan;
|
||||||
|
mode: "even" | "previous_month_pattern";
|
||||||
|
income: IncomePlan;
|
||||||
|
/** Bütçe ayı sonu tahmini gider (bütçe motorundan). */
|
||||||
|
forecastExpenseMinor: number;
|
||||||
|
/** Bu tempoda ay sonunda elde kalacak: beklenen gelir − tahmini gider. */
|
||||||
|
projectedLeftMinor: number;
|
||||||
|
/** Plana göre elde kalacak: beklenen gelir − bütçe. */
|
||||||
|
plannedLeftMinor: number;
|
||||||
|
next: { month: string; expectedIncomeMinor: number; budgetMinor: number | null };
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */
|
||||||
|
export async function getDailyPlan(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<DailyPlanView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const month = periodOfDate(today, s.startDay);
|
||||||
|
const period = budgetPeriod(month, s.startDay);
|
||||||
|
const nextMonth = shiftMonth(month, 1);
|
||||||
|
const mode = s.dailyMode;
|
||||||
|
const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([
|
||||||
|
getBudget(userId, householdId, month),
|
||||||
|
expensesByDay(ctx, s, period.from, period.to),
|
||||||
|
incomePlan(ctx, s, month),
|
||||||
|
incomePlan(ctx, s, nextMonth),
|
||||||
|
getBudget(userId, householdId, nextMonth),
|
||||||
|
]);
|
||||||
|
if (!budget) return null;
|
||||||
|
|
||||||
|
let weights: number[] | undefined;
|
||||||
|
if (mode === "previous_month_pattern") {
|
||||||
|
const prev = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const prevDays = await expensesByDay(ctx, s, prev.from, prev.to);
|
||||||
|
const len = daysBetween(prev.from, prev.to);
|
||||||
|
weights = patternWeights(
|
||||||
|
Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0),
|
||||||
|
daysBetween(period.from, period.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const plan = dailyPlan({
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
today,
|
||||||
|
budgetMinor: budget.totals.limitMinor,
|
||||||
|
fixedByDay: byDay.fixed,
|
||||||
|
actualByDay: byDay.actual,
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
const forecastExpense = budget.totals.forecastMinor;
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
period,
|
||||||
|
hasBudget: budget.hasBudget,
|
||||||
|
plan,
|
||||||
|
mode,
|
||||||
|
income,
|
||||||
|
forecastExpenseMinor: forecastExpense,
|
||||||
|
projectedLeftMinor: income.expectedMinor - forecastExpense,
|
||||||
|
plannedLeftMinor: income.expectedMinor - budget.totals.limitMinor,
|
||||||
|
next: {
|
||||||
|
month: nextMonth,
|
||||||
|
expectedIncomeMinor: nextIncome.expectedMinor,
|
||||||
|
budgetMinor: nextBudget?.hasBudget ? nextBudget.totals.limitMinor : null,
|
||||||
|
},
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -206,6 +206,30 @@ describe("aylık bütçe", () => {
|
|||||||
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
|
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => {
|
||||||
|
const F = shiftMonth(todayIstanbul().slice(0, 7), 24);
|
||||||
|
const f = budgetPeriod(F);
|
||||||
|
await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif });
|
||||||
|
await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", F))!;
|
||||||
|
expect(keyif.actualMinor).toBe(900_00);
|
||||||
|
// Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez.
|
||||||
|
expect(keyif.forecastMinor).toBe(900_00);
|
||||||
|
expect((await line("kira", F))!.forecastMinor).toBe(4_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const cur = today.slice(0, 7);
|
||||||
|
if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok
|
||||||
|
await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif });
|
||||||
|
await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true });
|
||||||
|
const keyif = (await line("keyif", cur))!;
|
||||||
|
expect(keyif.actualMinor).toBe(1_000_00);
|
||||||
|
expect(keyif.forecastMinor).toBeGreaterThan(1_000_00);
|
||||||
|
expect((await line("kira", cur))!.forecastMinor).toBe(2_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
|
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
|
||||||
await HouseholdModel.updateOne(
|
await HouseholdModel.updateOne(
|
||||||
{ _id: cakir },
|
{ _id: cakir },
|
||||||
|
|||||||
@@ -34,18 +34,20 @@ import type { BudgetLimitsForm } from "@/shared/schemas/budget";
|
|||||||
const t = tr.budget;
|
const t = tr.budget;
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
type Settings = {
|
export type Settings = {
|
||||||
startDay: number;
|
startDay: number;
|
||||||
installmentMode: "per_installment" | "purchase_month";
|
installmentMode: "per_installment" | "purchase_month";
|
||||||
includePrivate: boolean;
|
includePrivate: boolean;
|
||||||
|
dailyMode: "even" | "previous_month_pattern";
|
||||||
};
|
};
|
||||||
|
|
||||||
async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
|
export async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
|
||||||
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||||
return {
|
return {
|
||||||
startDay: h?.settings?.budgetMonthStartDay ?? 1,
|
startDay: h?.settings?.budgetMonthStartDay ?? 1,
|
||||||
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
|
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
|
||||||
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
|
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
|
||||||
|
dailyMode: h?.settings?.dailyPlanMode ?? "even",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,10 @@ export async function expenseAmounts(
|
|||||||
$project: {
|
$project: {
|
||||||
status: 1,
|
status: 1,
|
||||||
fixed: {
|
fixed: {
|
||||||
$or: [{ $ne: ["$recurringRuleId", null] }, { $ne: ["$installment.planId", null] }],
|
$or: [
|
||||||
|
{ $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] },
|
||||||
|
{ $ne: [{ $ifNull: ["$installment.planId", null] }, null] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
$cond: [
|
$cond: [
|
||||||
@@ -205,17 +210,32 @@ export async function getBudget(
|
|||||||
expenseAmounts(ctx, s, period.from, period.to),
|
expenseAmounts(ctx, s, period.from, period.to),
|
||||||
]);
|
]);
|
||||||
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
|
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
|
||||||
|
// Taban hız (bitmemiş aylar için): önceki dönemin değişken gideri, kategori bazında.
|
||||||
|
let baselineDays: number | undefined;
|
||||||
|
if (elapsedDays < totalDays) {
|
||||||
|
const prev = budgetPeriod(shiftMonth(m, -1), s.startDay);
|
||||||
|
const prevAmounts = await expenseAmounts(ctx, s, prev.from, prev.to);
|
||||||
|
let any = 0;
|
||||||
|
for (const [id, a] of prevAmounts) {
|
||||||
|
if (a.variableMinor <= 0) continue;
|
||||||
|
any += a.variableMinor;
|
||||||
|
const cur = amounts.get(id) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
amounts.set(id, { ...cur, baselineMinor: a.variableMinor });
|
||||||
|
}
|
||||||
|
if (any > 0) baselineDays = daysBetween(prev.from, prev.to);
|
||||||
|
}
|
||||||
const cats = expenseCategories(ctx);
|
const cats = expenseCategories(ctx);
|
||||||
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
|
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
|
||||||
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
|
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
|
||||||
const known = new Set(visibleCats.map((c) => c.id));
|
const known = new Set(visibleCats.map((c) => c.id));
|
||||||
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
|
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
|
||||||
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0, baselineMinor: 0 };
|
||||||
for (const [id, a] of amounts)
|
for (const [id, a] of amounts)
|
||||||
if (!known.has(id)) {
|
if (!known.has(id)) {
|
||||||
orphan.fixedMinor += a.fixedMinor;
|
orphan.fixedMinor += a.fixedMinor;
|
||||||
orphan.variableMinor += a.variableMinor;
|
orphan.variableMinor += a.variableMinor;
|
||||||
orphan.plannedMinor += a.plannedMinor;
|
orphan.plannedMinor += a.plannedMinor;
|
||||||
|
orphan.baselineMinor += a.baselineMinor ?? 0;
|
||||||
}
|
}
|
||||||
const ORPHAN = "__none__";
|
const ORPHAN = "__none__";
|
||||||
const inputCats = visibleCats.map((c) => ({
|
const inputCats = visibleCats.map((c) => ({
|
||||||
@@ -234,6 +254,7 @@ export async function getBudget(
|
|||||||
amounts,
|
amounts,
|
||||||
elapsedDays,
|
elapsedDays,
|
||||||
totalDays,
|
totalDays,
|
||||||
|
baselineDays,
|
||||||
});
|
});
|
||||||
const editorCats = cats.filter((c) => !c.archived);
|
const editorCats = cats.filter((c) => !c.archived);
|
||||||
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
|
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
|
||||||
|
|||||||
+55
-1
@@ -860,6 +860,60 @@ export const tr = {
|
|||||||
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
||||||
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
||||||
goToBudget: "Bütçeye git",
|
goToBudget: "Bütçeye git",
|
||||||
|
income: {
|
||||||
|
title: "Gelir planı",
|
||||||
|
description:
|
||||||
|
"Gerçekleşen gelirler, planlı (düzenli) gelirler ve elle eklenen beklenen gelirler.",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
pending: "Bekleyen planlı",
|
||||||
|
manual: "Elle eklenen",
|
||||||
|
expected: "Beklenen toplam",
|
||||||
|
plannedLeft: "Plana göre elde kalacak",
|
||||||
|
projectedLeft: "Bu tempoda elde kalacak",
|
||||||
|
plannedLeftHint: "Beklenen gelir − bütçe",
|
||||||
|
projectedLeftHint: "Beklenen gelir − ay sonu tahmini gider",
|
||||||
|
received: "Alındı",
|
||||||
|
planned: "Planlı",
|
||||||
|
empty: "Bu ay için planlı gelir yok.",
|
||||||
|
edit: "Beklenen gelir ekle",
|
||||||
|
editTitle: (month: string) => `Elle eklenen gelirler · ${month}`,
|
||||||
|
editHint:
|
||||||
|
"Planlı işlemi olmayan beklenen gelirler (ör. prim). Gelir gerçekleşip işlem olarak girildiğinde kalemi buradan kaldırın; aksi halde iki kez sayılır.",
|
||||||
|
itemName: "Açıklama",
|
||||||
|
itemAmount: "Tutar",
|
||||||
|
addItem: "Kalem ekle",
|
||||||
|
removeItem: "Kalemi kaldır",
|
||||||
|
saved: "Gelir planı kaydedildi.",
|
||||||
|
},
|
||||||
|
daily: {
|
||||||
|
title: "Günlük plan",
|
||||||
|
headline: (monthPct: number, spentPct: number) =>
|
||||||
|
`Bugün ayın %${monthPct}'si geçti, bütçenin %${spentPct}'i harcandı.`,
|
||||||
|
today: "Bugün için",
|
||||||
|
todayHint: "Bugünün planı + önceki günlerden kalan (veya aşan) tutar",
|
||||||
|
plannedToDate: "Bugüne kadar planlanan",
|
||||||
|
actualToDate: "Bugüne kadar harcanan",
|
||||||
|
projectedLeft: "Bu tempoda ay sonunda elde kalacak",
|
||||||
|
chartLabel: "Kümülatif planlanan ve gerçekleşen gider",
|
||||||
|
cumPlanned: "Planlanan (kümülatif)",
|
||||||
|
cumActual: "Gerçekleşen (kümülatif)",
|
||||||
|
income: "Beklenen gelir",
|
||||||
|
modes: {
|
||||||
|
even: "Değişken bütçe günlere eşit dağıtıldı.",
|
||||||
|
previous_month_pattern: "Değişken bütçe önceki ayın günlük örüntüsüne göre dağıtıldı.",
|
||||||
|
},
|
||||||
|
fixedNote: (amount: string) => `Tarihe bağlı giderler kendi günlerinde: ${amount}.`,
|
||||||
|
table: "Günleri tablo olarak göster",
|
||||||
|
day: "Gün",
|
||||||
|
planned: "Plan",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
noBudget: "Günlük plan için bu ayın bütçesini oluşturun.",
|
||||||
|
next: (month: string) => `Sonraki ay (${month})`,
|
||||||
|
nextIncome: "Beklenen gelir",
|
||||||
|
nextBudget: "Bütçe",
|
||||||
|
nextDiff: "Fark",
|
||||||
|
nextNoBudget: "Bütçe yok",
|
||||||
|
},
|
||||||
toast: {
|
toast: {
|
||||||
saved: "Bütçe kaydedildi.",
|
saved: "Bütçe kaydedildi.",
|
||||||
copied: "Önceki ayın limitleri kopyalandı.",
|
copied: "Önceki ayın limitleri kopyalandı.",
|
||||||
@@ -1093,7 +1147,7 @@ export const tr = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Ana sayfa",
|
title: "Ana sayfa",
|
||||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
welcome: (name: string) => `Hoş geldin, ${name}`,
|
||||||
welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.",
|
welcomeDescription: "Bu ayın özeti, günlük plan ve uyarılar.",
|
||||||
cards: {
|
cards: {
|
||||||
monthIncome: "Bu ay gelir",
|
monthIncome: "Bu ay gelir",
|
||||||
monthExpense: "Bu ay gider",
|
monthExpense: "Bu ay gider",
|
||||||
|
|||||||
@@ -12,11 +12,21 @@ const limitSchema = new Schema(
|
|||||||
{ _id: false },
|
{ _id: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Gelir planına elle eklenen beklenen gelir (planlı işlemi olmayan, ör. prim). */
|
||||||
|
const incomeItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
amountMinor: { type: Number, required: true, min: 1 },
|
||||||
|
},
|
||||||
|
{ _id: true },
|
||||||
|
);
|
||||||
|
|
||||||
const budgetSchema = new Schema(
|
const budgetSchema = new Schema(
|
||||||
{
|
{
|
||||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
||||||
limits: { type: [limitSchema], default: [] },
|
limits: { type: [limitSchema], default: [] },
|
||||||
|
incomeItems: { type: [incomeItemSchema], default: [] },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -40,13 +40,31 @@ describe("bütçe dönemi", () => {
|
|||||||
variableMinor: 3_000_00,
|
variableMinor: 3_000_00,
|
||||||
plannedMinor: 500_00,
|
plannedMinor: 500_00,
|
||||||
};
|
};
|
||||||
// 10/30 gün geçti: değişken 3.000 → 9.000
|
// Geçmiş yok: 10/30 gün geçti, değişken 3.000 → 9.000
|
||||||
expect(forecast(a, 10, 30)).toBe(10_500_00);
|
expect(forecast(a, 10, 30)).toBe(10_500_00);
|
||||||
expect(forecast(a, 30, 30)).toBe(4_500_00);
|
expect(forecast(a, 30, 30)).toBe(4_500_00);
|
||||||
expect(forecast(a, 0, 30)).toBe(1_500_00);
|
// Henüz başlamamış dönem: gerçekleşmiş değişken olduğu gibi, tempo yok.
|
||||||
|
expect(forecast(a, 0, 30)).toBe(4_500_00);
|
||||||
expect(roundUpLimit(1_234_56)).toBe(1_300_00);
|
expect(roundUpLimit(1_234_56)).toBe(1_300_00);
|
||||||
expect(roundUpLimit(1_200_00)).toBe(1_200_00);
|
expect(roundUpLimit(1_200_00)).toBe(1_200_00);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("taban hız: önceki ayın temposu ay başında ağır basar", () => {
|
||||||
|
// Ayın 2'sinde 6.000'lik tek harcama; önceki ay 30 günde 9.000 (günde 300).
|
||||||
|
const a: CategoryAmounts = {
|
||||||
|
fixedMinor: 0,
|
||||||
|
variableMinor: 6_000_00,
|
||||||
|
plannedMinor: 0,
|
||||||
|
baselineMinor: 9_000_00,
|
||||||
|
};
|
||||||
|
// Yalnızca bu ay: 6.000 × 30/2 = 90.000.
|
||||||
|
expect(forecast(a, 2, 30)).toBe(90_000_00);
|
||||||
|
// Karışım: hız = 2/30 × 3.000 + 28/30 × 300 = 480 → 6.000 + 28 × 480
|
||||||
|
expect(forecast(a, 2, 30, 30)).toBe(6_000_00 + 28 * 480_00);
|
||||||
|
// Gelecek ay: yalnızca taban; geçmiş ay: gerçekleşen.
|
||||||
|
expect(forecast({ ...a, variableMinor: 0 }, 0, 30, 30)).toBe(9_000_00);
|
||||||
|
expect(forecast(a, 30, 30, 30)).toBe(6_000_00);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bütçe toplama", () => {
|
describe("bütçe toplama", () => {
|
||||||
|
|||||||
+36
-16
@@ -62,6 +62,8 @@ export type CategoryAmounts = {
|
|||||||
variableMinor: number;
|
variableMinor: number;
|
||||||
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
|
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
|
||||||
plannedMinor: number;
|
plannedMinor: number;
|
||||||
|
/** Önceki dönemin değişken gideri (tahmin için taban hız; yoksa 0). */
|
||||||
|
baselineMinor?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BudgetLine = {
|
export type BudgetLine = {
|
||||||
@@ -96,18 +98,28 @@ export type BudgetTotals = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken giderin
|
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken gider ve kalan
|
||||||
* şimdiye kadarki günlük hızıyla ay sonuna yansıtılması. Geçmiş ayda tahmin = gerçekleşen;
|
* günler için günlük hız tahmini.
|
||||||
* gelecek ayda yalnızca planlı.
|
*
|
||||||
|
* Günlük hız, bu ayın hızı ile önceki dönemin değişken harcama hızının (taban) karışımıdır; ay
|
||||||
|
* ilerledikçe bu ayın ağırlığı artar (geçen gün / toplam gün). Böylece ay başındaki tek bir büyük
|
||||||
|
* harcama tüm aya yayılmaz. Taban yoksa (`baselineDays` verilmemiş: hanenin geçmişi yok) yalnızca
|
||||||
|
* bu ayın hızı kullanılır. Geçmiş ayda tahmin = gerçekleşen.
|
||||||
*/
|
*/
|
||||||
export function forecast(a: CategoryAmounts, elapsedDays: number, totalDays: number): number {
|
export function forecast(
|
||||||
const variable =
|
a: CategoryAmounts,
|
||||||
elapsedDays <= 0
|
elapsedDays: number,
|
||||||
? 0
|
totalDays: number,
|
||||||
: elapsedDays >= totalDays
|
baselineDays?: number,
|
||||||
? a.variableMinor
|
): number {
|
||||||
: Math.round((a.variableMinor * totalDays) / elapsedDays);
|
const remaining = Math.max(0, totalDays - Math.max(0, elapsedDays));
|
||||||
return a.fixedMinor + a.plannedMinor + variable;
|
const current = elapsedDays > 0 ? a.variableMinor / elapsedDays : 0;
|
||||||
|
let pace = current;
|
||||||
|
if (baselineDays && baselineDays > 0) {
|
||||||
|
const w = Math.min(1, Math.max(0, elapsedDays) / totalDays);
|
||||||
|
pace = w * current + (1 - w) * ((a.baselineMinor ?? 0) / baselineDays);
|
||||||
|
}
|
||||||
|
return a.fixedMinor + a.plannedMinor + a.variableMinor + Math.round(pace * remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeBudget(input: {
|
export function computeBudget(input: {
|
||||||
@@ -116,8 +128,10 @@ export function computeBudget(input: {
|
|||||||
amounts: Map<string, CategoryAmounts>;
|
amounts: Map<string, CategoryAmounts>;
|
||||||
elapsedDays: number;
|
elapsedDays: number;
|
||||||
totalDays: number;
|
totalDays: number;
|
||||||
|
/** Önceki dönemin gün sayısı (taban hız için); hanenin geçmişi yoksa verilmez. */
|
||||||
|
baselineDays?: number;
|
||||||
}): { lines: BudgetLine[]; totals: BudgetTotals } {
|
}): { lines: BudgetLine[]; totals: BudgetTotals } {
|
||||||
const { categories, limits, amounts, elapsedDays, totalDays } = input;
|
const { categories, limits, amounts, elapsedDays, totalDays, baselineDays } = input;
|
||||||
const byId = new Map(categories.map((c) => [c.id, c]));
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
const children = new Map<string, BudgetCategoryInput[]>();
|
const children = new Map<string, BudgetCategoryInput[]>();
|
||||||
const roots: BudgetCategoryInput[] = [];
|
const roots: BudgetCategoryInput[] = [];
|
||||||
@@ -130,7 +144,12 @@ export function computeBudget(input: {
|
|||||||
}
|
}
|
||||||
const sort = (list: BudgetCategoryInput[]) =>
|
const sort = (list: BudgetCategoryInput[]) =>
|
||||||
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||||
const zero: CategoryAmounts = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
const zero: CategoryAmounts = {
|
||||||
|
fixedMinor: 0,
|
||||||
|
variableMinor: 0,
|
||||||
|
plannedMinor: 0,
|
||||||
|
baselineMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Alt ağaç toplamları.
|
// Alt ağaç toplamları.
|
||||||
const subtree = new Map<string, CategoryAmounts>();
|
const subtree = new Map<string, CategoryAmounts>();
|
||||||
@@ -138,12 +157,13 @@ export function computeBudget(input: {
|
|||||||
if (seen.has(c.id)) return zero;
|
if (seen.has(c.id)) return zero;
|
||||||
seen.add(c.id);
|
seen.add(c.id);
|
||||||
const own = amounts.get(c.id) ?? zero;
|
const own = amounts.get(c.id) ?? zero;
|
||||||
const total = { ...own };
|
const total = { ...own, baselineMinor: own.baselineMinor ?? 0 };
|
||||||
for (const k of children.get(c.id) ?? []) {
|
for (const k of children.get(c.id) ?? []) {
|
||||||
const s = sumOf(k, seen);
|
const s = sumOf(k, seen);
|
||||||
total.fixedMinor += s.fixedMinor;
|
total.fixedMinor += s.fixedMinor;
|
||||||
total.variableMinor += s.variableMinor;
|
total.variableMinor += s.variableMinor;
|
||||||
total.plannedMinor += s.plannedMinor;
|
total.plannedMinor += s.plannedMinor;
|
||||||
|
total.baselineMinor += s.baselineMinor ?? 0;
|
||||||
}
|
}
|
||||||
subtree.set(c.id, total);
|
subtree.set(c.id, total);
|
||||||
return total;
|
return total;
|
||||||
@@ -163,7 +183,7 @@ export function computeBudget(input: {
|
|||||||
const isCovered = covered || limit !== null;
|
const isCovered = covered || limit !== null;
|
||||||
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
|
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
|
||||||
const actual = s.fixedMinor + s.variableMinor;
|
const actual = s.fixedMinor + s.variableMinor;
|
||||||
const fc = forecast(s, elapsedDays, totalDays);
|
const fc = forecast(s, elapsedDays, totalDays, baselineDays);
|
||||||
if (limit !== null || actual > 0 || s.plannedMinor > 0) {
|
if (limit !== null || actual > 0 || s.plannedMinor > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
categoryId: c.id,
|
categoryId: c.id,
|
||||||
@@ -192,7 +212,7 @@ export function computeBudget(input: {
|
|||||||
const s = subtree.get(r.id) ?? zero;
|
const s = subtree.get(r.id) ?? zero;
|
||||||
actual += s.fixedMinor + s.variableMinor;
|
actual += s.fixedMinor + s.variableMinor;
|
||||||
planned += s.plannedMinor;
|
planned += s.plannedMinor;
|
||||||
fc += forecast(s, elapsedDays, totalDays);
|
fc += forecast(s, elapsedDays, totalDays, baselineDays);
|
||||||
}
|
}
|
||||||
const hasLimit = limits.size > 0;
|
const hasLimit = limits.size > 0;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dailyPlan, distribute, patternWeights } from "./daily-plan";
|
||||||
|
|
||||||
|
describe("dağıtım", () => {
|
||||||
|
it("toplam korunur, kalan kuruşlar en büyük kesirlere", () => {
|
||||||
|
expect(distribute(100, [1, 1, 1])).toEqual([34, 33, 33]);
|
||||||
|
expect(distribute(10, [1, 0, 3])).toEqual([3, 0, 7]);
|
||||||
|
expect(distribute(5, [0, 0])).toEqual([3, 2]);
|
||||||
|
expect(distribute(0, [1, 2])).toEqual([0, 0]);
|
||||||
|
expect(distribute(7, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki ayın örüntüsü gün sayısına uyarlanır", () => {
|
||||||
|
expect(patternWeights([5, 0, 2], 4)).toEqual([5, 0, 2, 2]);
|
||||||
|
expect(patternWeights([5, 0, 2, 1], 2)).toEqual([5, 0]);
|
||||||
|
expect(patternWeights([], 2)).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("günlük plan", () => {
|
||||||
|
// Aralık: 31 gün. Bütçe 31.000 + kira 10.000 (1'inde) + fatura 3.100 (15'inde).
|
||||||
|
const base = {
|
||||||
|
from: "2026-12-01",
|
||||||
|
to: "2026-12-31",
|
||||||
|
budgetMinor: 44_100_00,
|
||||||
|
fixedByDay: new Map([
|
||||||
|
["2026-12-01", 10_000_00],
|
||||||
|
["2026-12-15", 3_100_00],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sabit giderler kendi gününe, değişken bütçe eşit; kümülatif ve yüzde", () => {
|
||||||
|
const p = dailyPlan({
|
||||||
|
...base,
|
||||||
|
today: "2026-12-13",
|
||||||
|
actualByDay: new Map([
|
||||||
|
["2026-12-01", 10_500_00],
|
||||||
|
["2026-12-05", 9_000_00],
|
||||||
|
["2026-12-13", 2_000_00],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
expect(p.variableMinor).toBe(31_000_00);
|
||||||
|
expect(p.days[0]!.plannedMinor).toBe(11_000_00);
|
||||||
|
expect(p.days[1]!.plannedMinor).toBe(1_000_00);
|
||||||
|
expect(p.days[14]!.plannedMinor).toBe(4_100_00);
|
||||||
|
expect(p.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||||
|
expect(p.todayIndex).toBe(12);
|
||||||
|
// 13/31 gün → %42; 21.500 / 44.100 → %49
|
||||||
|
expect(p.monthPct).toBe(42);
|
||||||
|
expect(p.spentPct).toBe(49);
|
||||||
|
expect(p.plannedToDateMinor).toBe(23_000_00);
|
||||||
|
expect(p.actualToDateMinor).toBe(21_500_00);
|
||||||
|
// Bugün: 1.000 + (12 günlük plan 22.000 − harcanan 19.500 = 2.500)
|
||||||
|
expect(p.todayAllowanceMinor).toBe(3_500_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sabit giderler bütçeyi aşarsa değişken 0; dönem dışı bugün; ağırlıklı dağıtım", () => {
|
||||||
|
const over = dailyPlan({
|
||||||
|
...base,
|
||||||
|
budgetMinor: 5_000_00,
|
||||||
|
today: "2027-01-05",
|
||||||
|
actualByDay: new Map(),
|
||||||
|
});
|
||||||
|
expect(over.variableMinor).toBe(0);
|
||||||
|
expect(over.todayIndex).toBe(-1);
|
||||||
|
expect(over.monthPct).toBe(100);
|
||||||
|
const weights = Array.from({ length: 31 }, (_, i) => (i % 7 === 5 ? 3 : 1));
|
||||||
|
const w = dailyPlan({ ...base, today: "2026-11-20", actualByDay: new Map(), weights });
|
||||||
|
expect(w.monthPct).toBe(0);
|
||||||
|
expect(w.days[5]!.plannedMinor).toBeGreaterThan(w.days[6]!.plannedMinor);
|
||||||
|
expect(w.days.at(-1)!.cumPlannedMinor).toBe(44_100_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { daysBetween } from "./budget";
|
||||||
|
import { addDays } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Günlük plan (Bölüm 6.9). Ayın bütçesi günlere yayılır: belirli tarihe bağlı giderler (düzenli
|
||||||
|
* işlemler, taksitler) kendi günlerine; kalan değişken bütçe günlere eşit (varsayılan) veya önceki
|
||||||
|
* ayın günlük harcama örüntüsüne göre (hane ayarı) dağıtılır. Tutarlar kuruş.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `total`ı ağırlıklara göre tamsayı paylara böler; toplam tam olarak `total` olur. */
|
||||||
|
export function distribute(total: number, weights: number[]): number[] {
|
||||||
|
const n = weights.length;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const sum = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||||
|
const w = sum > 0 ? weights.map((x) => Math.max(0, x)) : weights.map(() => 1);
|
||||||
|
const wsum = sum > 0 ? sum : n;
|
||||||
|
const exact = w.map((x) => (total * x) / wsum);
|
||||||
|
const out = exact.map(Math.floor);
|
||||||
|
let rest = total - out.reduce((a, b) => a + b, 0);
|
||||||
|
// En büyük kalanlara birer kuruş (eşitlikte erken gün önce).
|
||||||
|
const order = exact
|
||||||
|
.map((x, i) => ({ i, frac: x - Math.floor(x) }))
|
||||||
|
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||||||
|
for (let k = 0; rest > 0 && k < order.length; k++, rest--) out[order[k]!.i]! += 1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki dönemin günlük değişken harcamasını bu dönemin gün sayısına uyarlar (gün sırasıyla). */
|
||||||
|
export function patternWeights(previous: number[], totalDays: number): number[] {
|
||||||
|
if (!previous.length) return Array.from({ length: totalDays }, () => 1);
|
||||||
|
return Array.from({ length: totalDays }, (_, i) => previous[Math.min(i, previous.length - 1)]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanDay = {
|
||||||
|
date: string;
|
||||||
|
/** O günün planı: sabit giderler + değişken pay. */
|
||||||
|
plannedMinor: number;
|
||||||
|
fixedMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
cumPlannedMinor: number;
|
||||||
|
cumActualMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyPlan = {
|
||||||
|
days: PlanDay[];
|
||||||
|
budgetMinor: number;
|
||||||
|
fixedMinor: number;
|
||||||
|
variableMinor: number;
|
||||||
|
/** Bugünün sırası (0'dan); dönem dışındaysa -1. */
|
||||||
|
todayIndex: number;
|
||||||
|
/** Ayın geçen kısmı ve bütçenin harcanan kısmı (yüzde, tamsayı). */
|
||||||
|
monthPct: number;
|
||||||
|
spentPct: number | null;
|
||||||
|
/** Bugüne kadar planlanan ve gerçekleşen (kümülatif). */
|
||||||
|
plannedToDateMinor: number;
|
||||||
|
actualToDateMinor: number;
|
||||||
|
/** Bugün için kalan: bugünün planı + önceki günlerden kalan/aşan fark. */
|
||||||
|
todayAllowanceMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dailyPlan(input: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
today: string;
|
||||||
|
budgetMinor: number;
|
||||||
|
/** Tarihe bağlı (sabit) giderler, güne göre. */
|
||||||
|
fixedByDay: Map<string, number>;
|
||||||
|
/** Gerçekleşen tüm giderler, güne göre. */
|
||||||
|
actualByDay: Map<string, number>;
|
||||||
|
/** Değişken bütçe ağırlıkları (gün sayısı kadar); verilmezse eşit. */
|
||||||
|
weights?: number[];
|
||||||
|
}): DailyPlan {
|
||||||
|
const total = daysBetween(input.from, input.to);
|
||||||
|
const dates = Array.from({ length: total }, (_, i) => addDays(input.from, i));
|
||||||
|
let fixed = 0;
|
||||||
|
for (const d of dates) fixed += input.fixedByDay.get(d) ?? 0;
|
||||||
|
const variable = Math.max(0, input.budgetMinor - fixed);
|
||||||
|
const shares = distribute(variable, input.weights ?? dates.map(() => 1));
|
||||||
|
let cumP = 0;
|
||||||
|
let cumA = 0;
|
||||||
|
const days = dates.map((date, i): PlanDay => {
|
||||||
|
const f = input.fixedByDay.get(date) ?? 0;
|
||||||
|
const planned = f + shares[i]!;
|
||||||
|
const actual = input.actualByDay.get(date) ?? 0;
|
||||||
|
cumP += planned;
|
||||||
|
cumA += actual;
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
plannedMinor: planned,
|
||||||
|
fixedMinor: f,
|
||||||
|
actualMinor: actual,
|
||||||
|
cumPlannedMinor: cumP,
|
||||||
|
cumActualMinor: cumA,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const todayIndex =
|
||||||
|
input.today < input.from || input.today > input.to
|
||||||
|
? -1
|
||||||
|
: daysBetween(input.from, input.today) - 1;
|
||||||
|
const passed = input.today < input.from ? 0 : input.today > input.to ? total : todayIndex + 1;
|
||||||
|
const upTo = passed > 0 ? days[passed - 1]! : null;
|
||||||
|
const before = todayIndex > 0 ? days[todayIndex - 1]! : null;
|
||||||
|
const todayDay = todayIndex >= 0 ? days[todayIndex]! : null;
|
||||||
|
const actualToDate = upTo?.cumActualMinor ?? 0;
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
budgetMinor: input.budgetMinor,
|
||||||
|
fixedMinor: fixed,
|
||||||
|
variableMinor: variable,
|
||||||
|
todayIndex,
|
||||||
|
monthPct: Math.round((passed * 100) / total),
|
||||||
|
spentPct: input.budgetMinor > 0 ? Math.round((actualToDate * 100) / input.budgetMinor) : null,
|
||||||
|
plannedToDateMinor: upTo?.cumPlannedMinor ?? 0,
|
||||||
|
actualToDateMinor: actualToDate,
|
||||||
|
todayAllowanceMinor: todayDay
|
||||||
|
? todayDay.plannedMinor + (before ? before.cumPlannedMinor - before.cumActualMinor : 0)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,3 +27,20 @@ export const budgetLimitsSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||||
|
|
||||||
|
/** Gelir planı: elle eklenen beklenen gelirler (liste tümüyle değiştirilir). */
|
||||||
|
export const incomeItemsSchema = z.object({
|
||||||
|
month: budgetMonthSchema,
|
||||||
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax),
|
||||||
|
amount: nonNegativeMoneySchema.refine(
|
||||||
|
(n) => n > 0,
|
||||||
|
tr.transactions.validation.amountPositive,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(30),
|
||||||
|
});
|
||||||
|
export type IncomeItemsForm = z.output<typeof incomeItemsSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user