feat: Faz 7b - kart ve KMH dönemleri, taksitler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+21
-1
@@ -6,7 +6,27 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
Faz 7 (alt fazlar halinde; `v0.7.0` Faz 7b sonunda).
|
## [0.7.0] - 2026-11-05
|
||||||
|
|
||||||
|
Faz 7: planlı/düzenli işlemler, kredi kartı ve KMH dönemleri, taksitler (7a ve 7b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 7b: kart ve KMH dönemleri, taksitler
|
||||||
|
|
||||||
|
- Dönem hesabı (`shared/card-periods.ts`): kesim günü dahil "Hesap Kesim Dönemi", sonrası "Güncel Dönem"; kesim günü ayda yoksa ayın son günü; kesim günü bitmeden dönem kapanmış sayılmaz. Son ödeme: kesimden N gün sonra veya ayın sabit günü.
|
||||||
|
- Kart özeti (`/hesaplar/:id`, kredi kartı): toplam borç, kullanılabilir limit (gelecek taksitler dahil düşülür) ve kullanım çubuğu; hesap kesim dönemi borcu, asgari ödeme, ödenen, kalan, son ödeme tarihi ve kalan gün; güncel dönem harcaması; dönem dönem yaklaşan taksitler; planlı harcamalar; son 6 ekstre.
|
||||||
|
- Asgari ödeme: limit 50.000 TL altı %20, üstü %40; kart bazında oran girilebilir (hesap formu).
|
||||||
|
- "Borcu öde": ödeme hesabından karta transfer formu kalan dönem borcuyla dolu açılır; ödemeler dönem borcundan düşer (kısmi ödeme desteklenir).
|
||||||
|
- Taksitli harcama: kredi kartı giderinde taksit sayısı (2–36). Her taksit ayrı işlem ("Hepsiburada 4/9"); toplam kuruş kaybı olmadan bölünür; vadesi gelen taksit kendi döneminde otomatik gerçekleşir, gelecek taksitler planlıdır ve bakiyeyi etkilemez. Ayrıntıdan "Kalan taksitleri iptal et".
|
||||||
|
- KMH özeti (KMH'li banka hesabı): limit, kullanılan, kullanılabilir; geçen ve bu dönem için gün sonu eksi bakiyelerden tahmini faiz; "Faiz kaydı ekle" kesim tarihli, "Banka › Faiz" kategorili gider formunu doldurur (vergiler hariç not).
|
||||||
|
- `statements` koleksiyonu: kart/KMH dönem kayıtları (borç, asgari, ödenen, harcama, tahmini faiz); kart özeti okunurken işlemlerden yeniden hesaplanır.
|
||||||
|
- İşlem formuna ön doldurma desteği (kart ödemesi, faiz kaydı).
|
||||||
|
- Seed: iki taksitli kart alışverişi.
|
||||||
|
- `006` migration'ı: `statements` index'leri, taksit planı index'i.
|
||||||
|
- Testler: dönem/son ödeme/asgari/taksit planı/KMH faizi (birim); kart özeti tutarlılığı (bağımsız bakiye hesabıyla), ödeme etkisi, taksit üretimi ve otomatik gerçekleşme, taksit iptali, taksit kuralları, KMH faiz tahmini ve yetki (entegrasyon).
|
||||||
|
|
||||||
|
### Düzeltildi — Faz 7b
|
||||||
|
|
||||||
|
- Seed: aynı aydaki satıcısız/kategorisiz transferler aynı `seedRef`'i aldığı için kart borcu ödemesi ve aylık birikim transferleri hiç oluşmuyordu; artık oluşuyor (mevcut kayıtlar kopyalanmaz). Kart ödemesi son ödeme gününden önceye alındı.
|
||||||
|
|
||||||
### Eklendi — Faz 7a: planlı ve düzenli işlemler
|
### Eklendi — Faz 7a: planlı ve düzenli işlemler
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.6.0",
|
"version": "0.7.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.6.0",
|
"version": "0.7.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": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "006-card-statements",
|
||||||
|
description: "statements (kart/KMH dönemleri) index'leri; taksit planı index'i",
|
||||||
|
async up() {
|
||||||
|
await CardStatementModel.createIndexes();
|
||||||
|
await TransactionModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,10 +5,11 @@ import { migration as m002 } from "./002-accounts-categories";
|
|||||||
import { migration as m003 } from "./003-transactions";
|
import { migration as m003 } from "./003-transactions";
|
||||||
import { migration as m004 } from "./004-attachments-reconciliation";
|
import { migration as m004 } from "./004-attachments-reconciliation";
|
||||||
import { migration as m005 } from "./005-recurring";
|
import { migration as m005 } from "./005-recurring";
|
||||||
|
import { migration as m006 } from "./006-card-statements";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005];
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { createTransaction } from "@/features/transactions/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Örnek taksitli kart harcamaları (Faz 7b): bir kısmı gerçekleşmiş, kalanı planlı taksitler.
|
||||||
|
* İdempotent: aynı satıcıyla taksit planı varsa atlanır.
|
||||||
|
*/
|
||||||
|
const PURCHASES = [
|
||||||
|
{
|
||||||
|
payee: "Hepsiburada",
|
||||||
|
description: "Çamaşır makinesi",
|
||||||
|
amountMinor: 18_000_00,
|
||||||
|
count: 9,
|
||||||
|
daysAgo: 75,
|
||||||
|
category: "ev",
|
||||||
|
by: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
payee: "Teknosa",
|
||||||
|
description: "Telefon",
|
||||||
|
amountMinor: 12_000_00,
|
||||||
|
count: 6,
|
||||||
|
daysAgo: 20,
|
||||||
|
category: "diger-gider",
|
||||||
|
by: "emre",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedInstallments(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
userIds: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const card = await AccountModel.findOne({ householdId, name: "Garanti Bonus" }).lean();
|
||||||
|
if (!card) return 0;
|
||||||
|
let created = 0;
|
||||||
|
for (const p of PURCHASES) {
|
||||||
|
const exists = await TransactionModel.exists({
|
||||||
|
householdId,
|
||||||
|
payeeName: p.payee,
|
||||||
|
"installment.planId": { $type: "objectId" },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
const category = await CategoryModel.findOne({ householdId, defaultKey: p.category }).lean();
|
||||||
|
const r = await createTransaction(userIds.get(p.by)!, String(householdId), {
|
||||||
|
type: "expense",
|
||||||
|
amount: p.amountMinor,
|
||||||
|
date: addDays(today, -p.daysAgo),
|
||||||
|
accountId: String(card._id),
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: String(category!._id),
|
||||||
|
splits: [],
|
||||||
|
payee: p.payee,
|
||||||
|
description: p.description,
|
||||||
|
tags: [],
|
||||||
|
installments: p.count,
|
||||||
|
attachmentIds: [],
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Seed taksiti oluşturulamadı (${p.payee}): ${r.error}`);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -46,11 +46,27 @@ describe("seed", () => {
|
|||||||
accounts: 0,
|
accounts: 0,
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
recurringRules: 0,
|
recurringRules: 0,
|
||||||
|
installmentPlans: 0,
|
||||||
});
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aynı aydaki transferlerin hepsi oluşur (seedRef çakışması yok)", async () => {
|
||||||
|
const tx = getDb().collection("transactions");
|
||||||
|
const months = new Set(
|
||||||
|
(await tx.find({ description: "ATM" }).toArray()).map((t) => t.seedRef.split(":")[1]),
|
||||||
|
);
|
||||||
|
expect(months.size).toBeGreaterThanOrEqual(5);
|
||||||
|
// Her ay ATM'nin yanında kart ödemesi ve birikim de var (ayın 7'si geçmişse).
|
||||||
|
expect(await tx.countDocuments({ description: "Kart borcu ödemesi" })).toBeGreaterThanOrEqual(
|
||||||
|
months.size - 1,
|
||||||
|
);
|
||||||
|
expect(await tx.countDocuments({ description: "Aylık birikim" })).toBeGreaterThanOrEqual(
|
||||||
|
months.size - 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("roller ve askı durumu doğru", async () => {
|
it("roller ve askı durumu doğru", async () => {
|
||||||
const users = await getDb()
|
const users = await getDb()
|
||||||
.collection("user")
|
.collection("user")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { HouseholdModel } from "@/models/household";
|
|||||||
import { HouseholdMemberModel } from "@/models/household-member";
|
import { HouseholdMemberModel } from "@/models/household-member";
|
||||||
import { storedEmail } from "@/shared/account-email";
|
import { storedEmail } from "@/shared/account-email";
|
||||||
import { runMigrations } from "../migrations";
|
import { runMigrations } from "../migrations";
|
||||||
|
import { seedInstallments } from "./installments";
|
||||||
import { seedRecurring } from "./recurring";
|
import { seedRecurring } from "./recurring";
|
||||||
import { seedTransactions } from "./transactions";
|
import { seedTransactions } from "./transactions";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ export type SeedSummary = {
|
|||||||
accounts: number;
|
accounts: number;
|
||||||
transactions: number;
|
transactions: number;
|
||||||
recurringRules: number;
|
recurringRules: number;
|
||||||
|
installmentPlans: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -61,6 +63,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"attachments",
|
"attachments",
|
||||||
"reconciliations",
|
"reconciliations",
|
||||||
"recurringRules",
|
"recurringRules",
|
||||||
|
"statements",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -111,6 +114,7 @@ export async function runSeed({
|
|||||||
accounts: 0,
|
accounts: 0,
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
recurringRules: 0,
|
recurringRules: 0,
|
||||||
|
installmentPlans: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -279,6 +283,12 @@ export async function runSeed({
|
|||||||
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
|
summary.created.recurringRules = await seedRecurring(householdIds, userIdMap);
|
||||||
if (summary.created.recurringRules)
|
if (summary.created.recurringRules)
|
||||||
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
|
log(`✓ Örnek düzenli işlemler: ${summary.created.recurringRules}`);
|
||||||
|
summary.created.installmentPlans = await seedInstallments(
|
||||||
|
householdIds.get("Çakır Ailesi")!,
|
||||||
|
userIdMap,
|
||||||
|
);
|
||||||
|
if (summary.created.installmentPlans)
|
||||||
|
log(`✓ Örnek taksitli alışverişler: ${summary.created.installmentPlans}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ function cakirAiles(): SeedTx[] {
|
|||||||
by: "emre",
|
by: "emre",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transferler: nakit çekme, kart ödemesi, birikim.
|
// Transferler: nakit çekme, kart ödemesi, birikim. Satıcı/kategori olmadığından seedRef
|
||||||
|
// türden üretilir; çakışmasın diye sıra numarası verilir (ATM 0 — mevcut veriyle uyumlu).
|
||||||
add({
|
add({
|
||||||
type: "transfer",
|
type: "transfer",
|
||||||
ymd: d(2),
|
ymd: d(2),
|
||||||
@@ -176,24 +177,31 @@ function cakirAiles(): SeedTx[] {
|
|||||||
description: "ATM",
|
description: "ATM",
|
||||||
by: "admin",
|
by: "admin",
|
||||||
});
|
});
|
||||||
add({
|
add(
|
||||||
type: "transfer",
|
{
|
||||||
ymd: d(9),
|
type: "transfer",
|
||||||
amountMinor: tl(11_000, 15_000),
|
// Kesim 29, son ödeme ~8: ödeme son ödeme gününden önce.
|
||||||
account: "Ziraat Maaş",
|
ymd: d(7),
|
||||||
toAccount: "Garanti Bonus",
|
amountMinor: tl(11_000, 15_000),
|
||||||
description: "Kart borcu ödemesi",
|
account: "Ziraat Maaş",
|
||||||
by: "admin",
|
toAccount: "Garanti Bonus",
|
||||||
});
|
description: "Kart borcu ödemesi",
|
||||||
add({
|
by: "admin",
|
||||||
type: "transfer",
|
},
|
||||||
ymd: d(16),
|
1,
|
||||||
amountMinor: 5_000_00,
|
);
|
||||||
account: "Ziraat Maaş",
|
add(
|
||||||
toAccount: "Birikim (Vadeli)",
|
{
|
||||||
description: "Aylık birikim",
|
type: "transfer",
|
||||||
by: "ayse",
|
ymd: d(16),
|
||||||
});
|
amountMinor: 5_000_00,
|
||||||
|
account: "Ziraat Maaş",
|
||||||
|
toAccount: "Birikim (Vadeli)",
|
||||||
|
description: "Aylık birikim",
|
||||||
|
by: "ayse",
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
// Market: haftada 1–2; bir kısmı bölünmüş.
|
// Market: haftada 1–2; bir kısmı bölünmüş.
|
||||||
const marketCount = faker.number.int({ min: 6, max: 8 });
|
const marketCount = faker.number.int({ min: 6, max: 8 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { StatementView } from "@/features/transactions/components/statement-view";
|
import { StatementView } from "@/features/transactions/components/statement-view";
|
||||||
|
import { getCardSummary, getOverdraftSummary } from "@/features/transactions/card";
|
||||||
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
import { currentMonth, getAccountStatement } from "@/features/transactions/statement";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
@@ -21,10 +22,20 @@ export default async function AccountStatementPage({
|
|||||||
const month = requested > now ? now : requested;
|
const month = requested > now ? now : requested;
|
||||||
const statement = await getAccountStatement(user.id, household.id, id, month);
|
const statement = await getAccountStatement(user.id, household.id, id, month);
|
||||||
if (!statement) notFound();
|
if (!statement) notFound();
|
||||||
|
const [card, overdraft] = await Promise.all([
|
||||||
|
statement.account.type === "credit_card"
|
||||||
|
? getCardSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
statement.account.type === "bank"
|
||||||
|
? getOverdraftSummary(user.id, household.id, id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<StatementView
|
<StatementView
|
||||||
key={`${id}-${month}`}
|
key={`${id}-${month}`}
|
||||||
statement={statement}
|
statement={statement}
|
||||||
|
card={card}
|
||||||
|
overdraft={overdraft}
|
||||||
canWrite={hasHouseholdRole(household.role, "member")}
|
canWrite={hasHouseholdRole(household.role, "member")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = 7.1;
|
export const CURRENT_PHASE = 7.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type FormValues = {
|
|||||||
cardDueKind: AccountFormInput["cardDueKind"];
|
cardDueKind: AccountFormInput["cardDueKind"];
|
||||||
cardDueValue: string;
|
cardDueValue: string;
|
||||||
cardPaymentAccountId: string;
|
cardPaymentAccountId: string;
|
||||||
|
cardMinimumRate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
|
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
|
||||||
@@ -105,6 +106,7 @@ function toValues(a?: AccountView): FormValues {
|
|||||||
cardDueKind: "daysAfter",
|
cardDueKind: "daysAfter",
|
||||||
cardDueValue: "10",
|
cardDueValue: "10",
|
||||||
cardPaymentAccountId: "",
|
cardPaymentAccountId: "",
|
||||||
|
cardMinimumRate: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const isCard = a.type === "credit_card";
|
const isCard = a.type === "credit_card";
|
||||||
@@ -133,6 +135,10 @@ function toValues(a?: AccountView): FormValues {
|
|||||||
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
|
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
|
||||||
cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
|
cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
|
||||||
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
|
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
|
||||||
|
cardMinimumRate:
|
||||||
|
a.creditCard?.minimumPaymentRateBp != null
|
||||||
|
? formatMoneyInput(a.creditCard.minimumPaymentRateBp)
|
||||||
|
: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +505,28 @@ export function AccountFormDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="cardMinimumRate"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="acc-card-min">{f.cardMinimumRate}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="acc-card-min"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="20"
|
||||||
|
className="w-28 tabular-nums"
|
||||||
|
aria-describedby="acc-card-min-hint"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
<FieldDescription id="acc-card-min-hint">
|
||||||
|
{f.cardMinimumRateHint}
|
||||||
|
</FieldDescription>
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const base = {
|
|||||||
cardDueKind: "daysAfter" as const,
|
cardDueKind: "daysAfter" as const,
|
||||||
cardDueValue: 10,
|
cardDueValue: 10,
|
||||||
cardPaymentAccountId: null,
|
cardPaymentAccountId: null,
|
||||||
|
cardMinimumRate: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountId = (name: string, householdId = cakir) =>
|
const accountId = (name: string, householdId = cakir) =>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type AccountView = {
|
|||||||
statementDay: number;
|
statementDay: number;
|
||||||
dueRule: { kind: DueRuleKind; value: number };
|
dueRule: { kind: DueRuleKind; value: number };
|
||||||
paymentAccountId: string | null;
|
paymentAccountId: string | null;
|
||||||
|
minimumPaymentRateBp: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,6 +230,7 @@ async function toDocFields(
|
|||||||
statementDay: input.cardStatementDay,
|
statementDay: input.cardStatementDay,
|
||||||
dueRule: { kind: input.cardDueKind, value: input.cardDueValue },
|
dueRule: { kind: input.cardDueKind, value: input.cardDueValue },
|
||||||
paymentAccountId,
|
paymentAccountId,
|
||||||
|
minimumPaymentRateBp: input.cardMinimumRate,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
|
|||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ describe("kural ve planlı kayıtlar", () => {
|
|||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
});
|
});
|
||||||
expect(u.ok).toBe(true);
|
expect(u.ok).toBe(true);
|
||||||
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
|
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function asTransactionForm(input: RecurringRuleForm): TransactionForm {
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getHouseholdActionContext } from "@/lib/dal";
|
|||||||
import { reconcileSchema } from "@/shared/schemas/reconciliation";
|
import { reconcileSchema } from "@/shared/schemas/reconciliation";
|
||||||
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
import {
|
import {
|
||||||
|
cancelRemainingInstallments,
|
||||||
createTransaction,
|
createTransaction,
|
||||||
getTransaction,
|
getTransaction,
|
||||||
getTransactionFormOptions,
|
getTransactionFormOptions,
|
||||||
@@ -106,6 +107,19 @@ export async function searchPayeesAction(query: unknown): Promise<PayeeSuggestio
|
|||||||
return searchPayees(ctx.user.id, ctx.household.id, q.data);
|
return searchPayees(ctx.user.id, ctx.household.id, q.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Taksitli alışverişin kalan (gerçekleşmemiş) taksitlerini iptal eder. */
|
||||||
|
export async function cancelRemainingInstallmentsAction(
|
||||||
|
transactionId: unknown,
|
||||||
|
): Promise<ActionResult<{ cancelled: number }>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = idSchema.safeParse(transactionId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
const result = await cancelRemainingInstallments(ctx.user.id, ctx.household.id, id.data);
|
||||||
|
if (result.ok) revalidate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */
|
/** Mutabakat önizlemesi: ekstre bakiyesi ile uygulama bakiyesi arasındaki fark. */
|
||||||
export async function previewReconciliationAction(
|
export async function previewReconciliationAction(
|
||||||
accountId: unknown,
|
accountId: unknown,
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createAccount, listAccounts } from "@/features/accounts/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { cardPeriods, minimumPayment } from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction";
|
||||||
|
import { transactionEffects } from "@/shared/transactions";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getCardSummary, getOverdraftSummary } from "./card";
|
||||||
|
import {
|
||||||
|
cancelRemainingInstallments,
|
||||||
|
createTransaction,
|
||||||
|
listTransactions,
|
||||||
|
postDueInstallments,
|
||||||
|
} from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let fatma = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => String(a!._id));
|
||||||
|
const cat = (key: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey: key })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
|
||||||
|
return {
|
||||||
|
type: "expense",
|
||||||
|
amount: 100_00,
|
||||||
|
date: today,
|
||||||
|
accountId: await acc("Garanti Bonus"),
|
||||||
|
toAccountId: null,
|
||||||
|
categoryId: await cat("giyim"),
|
||||||
|
splits: [],
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
installments: 1,
|
||||||
|
attachmentIds: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bağımsız hesap: açılış + belirli andan önceki gerçekleşmiş işlemler. */
|
||||||
|
async function balanceAt(accountId: string, before?: Date) {
|
||||||
|
const account = (await AccountModel.findById(accountId).lean())!;
|
||||||
|
const txs = await TransactionModel.find({
|
||||||
|
householdId: cakir,
|
||||||
|
deletedAt: null,
|
||||||
|
...(before ? { date: { $lt: before } } : {}),
|
||||||
|
}).lean();
|
||||||
|
let b = account.openingBalanceMinor;
|
||||||
|
for (const t of txs) {
|
||||||
|
for (const e of transactionEffects({
|
||||||
|
type: t.type,
|
||||||
|
status: t.status,
|
||||||
|
amountMinor: t.amountMinor,
|
||||||
|
accountId: String(t.accountId),
|
||||||
|
toAccountId: t.toAccountId ? String(t.toAccountId) : null,
|
||||||
|
}))
|
||||||
|
if (e.accountId === accountId) b += e.deltaMinor;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kart özeti", () => {
|
||||||
|
it("anlık borç, ekstre borcu (kesim günü dahil) ve asgari ödeme işlemlerle tutarlı", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const s = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const { statement, current } = cardPeriods(today, 29);
|
||||||
|
expect(s.statement.period).toEqual(statement);
|
||||||
|
expect(s.current.start).toBe(current.start);
|
||||||
|
expect(s.debtMinor).toBe(Math.max(0, -(await balanceAt(card))));
|
||||||
|
const endEx = new Date(istanbulDayStart(statement.end)!.getTime() + 24 * 3600 * 1000);
|
||||||
|
expect(s.statement.debtMinor).toBe(Math.max(0, -(await balanceAt(card, endEx))));
|
||||||
|
// Limit 60.000 TL ≥ 50.000 → %40.
|
||||||
|
expect(s.minimumRateBp).toBe(4000);
|
||||||
|
expect(s.statement.minimumMinor).toBe(minimumPayment(s.statement.debtMinor, 4000));
|
||||||
|
expect(s.history).toHaveLength(6);
|
||||||
|
expect(s.paymentAccount?.name).toBe("Ziraat Maaş");
|
||||||
|
expect(await CardStatementModel.countDocuments({ accountId: card })).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("karta ödeme ekstre borcunu düşürür", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const before = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({
|
||||||
|
type: "transfer",
|
||||||
|
amount: 1_000_00,
|
||||||
|
accountId: await acc("Ziraat Maaş"),
|
||||||
|
toAccountId: card,
|
||||||
|
categoryId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const after = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
expect(after.statement.paidMinor).toBe(before.statement.paidMinor + 1_000_00);
|
||||||
|
expect(after.debtMinor).toBe(before.debtMinor - 1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki: hane dışı ve kart olmayan hesap", async () => {
|
||||||
|
expect(await getCardSummary(fatma, cakir, await acc("Garanti Bonus"))).toBeNull();
|
||||||
|
expect(await getCardSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taksitli harcama", () => {
|
||||||
|
let first = "";
|
||||||
|
|
||||||
|
it("her taksit ayrı işlem; vadesi gelenler gerçekleşir, gelecekler planlı ve limitten düşer", async () => {
|
||||||
|
const card = await acc("Garanti Bonus");
|
||||||
|
const balanceBefore = await balanceAt(card);
|
||||||
|
const summaryBefore = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const date = addDays(today, -65);
|
||||||
|
const r = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ amount: 9_000_00, installments: 9, payee: "Hepsiburada", date }),
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
first = r.ok ? r.data.id : "";
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const all = await TransactionModel.find({ "installment.planId": plan })
|
||||||
|
.sort({ "installment.index": 1 })
|
||||||
|
.lean();
|
||||||
|
expect(all).toHaveLength(9);
|
||||||
|
expect(all.map((t) => t.installment!.index)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||||
|
expect(all.reduce((a, t) => a + t.amountMinor, 0)).toBe(9_000_00);
|
||||||
|
const posted = all.filter((t) => t.status === "posted");
|
||||||
|
expect(posted.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(all.filter((t) => t.status === "planned").every((t) => t.date > new Date())).toBe(true);
|
||||||
|
expect(await balanceAt(card)).toBe(
|
||||||
|
balanceBefore - posted.reduce((a, t) => a + t.amountMinor, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const s = (await getCardSummary(ayse, cakir, card))!;
|
||||||
|
const pending = all
|
||||||
|
.filter((t) => t.status === "planned")
|
||||||
|
.reduce((a, t) => a + t.amountMinor, 0);
|
||||||
|
expect(s.pendingInstallmentsMinor).toBe(summaryBefore.pendingInstallmentsMinor + pending);
|
||||||
|
expect(s.availableMinor).toBe(s.limitMinor - s.debtMinor - s.pendingInstallmentsMinor);
|
||||||
|
expect(s.upcomingInstallments.reduce((a, x) => a + x.totalMinor, 0)).toBe(
|
||||||
|
s.pendingInstallmentsMinor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await listTransactions(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
(await import("@/shared/schemas/transaction")).transactionFilterSchema.parse({
|
||||||
|
q: "Hepsiburada",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(list!.items[0]!.installment).toMatchObject({ count: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vadesi gelen taksit kendi döneminde otomatik gerçekleşir", async () => {
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const plannedBefore = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
await postDueInstallments(
|
||||||
|
(await AccountModel.findById(await acc("Garanti Bonus")).lean())!.householdId,
|
||||||
|
addDays(today, 40),
|
||||||
|
);
|
||||||
|
const plannedAfter = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
expect(plannedAfter).toBe(plannedBefore - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kalan taksitler iptal edilebilir; gerçekleşenler korunur", async () => {
|
||||||
|
const plan = (await TransactionModel.findById(first).lean())!.installment!.planId;
|
||||||
|
const posted = await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "posted",
|
||||||
|
});
|
||||||
|
const r = await cancelRemainingInstallments(ayse, cakir, first);
|
||||||
|
expect(r).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await TransactionModel.countDocuments({
|
||||||
|
"installment.planId": plan,
|
||||||
|
status: "posted",
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
).toBe(posted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taksit yalnızca kredi kartı giderinde", async () => {
|
||||||
|
const onBank = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ installments: 3, accountId: await acc("Ziraat Maaş") }),
|
||||||
|
);
|
||||||
|
expect(onBank.ok).toBe(false);
|
||||||
|
const base = {
|
||||||
|
type: "income",
|
||||||
|
amount: "100",
|
||||||
|
date: today,
|
||||||
|
accountId: "a".repeat(24),
|
||||||
|
categoryId: "b".repeat(24),
|
||||||
|
installments: "3",
|
||||||
|
};
|
||||||
|
expect(transactionFormSchema.safeParse(base).success).toBe(false);
|
||||||
|
expect(transactionFormSchema.safeParse({ ...base, type: "expense" }).success).toBe(true);
|
||||||
|
expect(
|
||||||
|
transactionFormSchema.safeParse({ ...base, type: "expense", installments: "37" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
transactionFormSchema.parse({ ...base, type: "expense", installments: "" }).installments,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KMH özeti", () => {
|
||||||
|
it("dönem içi eksi bakiyeden faiz tahmini; kullanılabilir KMH", async () => {
|
||||||
|
const created = await createAccount(ayse, cakir, {
|
||||||
|
name: "KMH Test",
|
||||||
|
type: "bank",
|
||||||
|
institution: "",
|
||||||
|
mealCardProvider: null,
|
||||||
|
openingBalance: 0,
|
||||||
|
openingDate: "2020-01-01",
|
||||||
|
color: "blue",
|
||||||
|
icon: "landmark",
|
||||||
|
ownerUserId: null,
|
||||||
|
isPrivate: false,
|
||||||
|
overdraftEnabled: true,
|
||||||
|
overdraftLimit: 10_000_00,
|
||||||
|
overdraftMonthlyRate: 425,
|
||||||
|
overdraftStatementDay: 1,
|
||||||
|
cardLimit: 0,
|
||||||
|
cardStatementDay: 1,
|
||||||
|
cardDueKind: "daysAfter",
|
||||||
|
cardDueValue: 10,
|
||||||
|
cardPaymentAccountId: null,
|
||||||
|
cardMinimumRate: null,
|
||||||
|
});
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
const id = created.ok ? created.data.id : "";
|
||||||
|
const { statement } = cardPeriods(today, 1);
|
||||||
|
await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
await form({ accountId: id, amount: 3_000_00, date: statement.start }),
|
||||||
|
);
|
||||||
|
const s = (await getOverdraftSummary(ayse, cakir, id))!;
|
||||||
|
const days =
|
||||||
|
Math.round(
|
||||||
|
(istanbulDayStart(statement.end)!.getTime() -
|
||||||
|
istanbulDayStart(statement.start)!.getTime()) /
|
||||||
|
(24 * 3600 * 1000),
|
||||||
|
) + 1;
|
||||||
|
expect(s.statement.interestMinor).toBe(Math.round((days * 3_000_00 * 425) / 10_000 / 30));
|
||||||
|
expect(s.usedMinor).toBe(3_000_00);
|
||||||
|
expect(s.availableMinor).toBe(7_000_00);
|
||||||
|
// Artı bakiyeli hesapta kullanılabilir KMH limitin kendisi.
|
||||||
|
const z = (await getOverdraftSummary(ayse, cakir, await acc("Ziraat Maaş")))!;
|
||||||
|
expect(z.usedMinor).toBe(0);
|
||||||
|
expect(z.availableMinor).toBe(z.limitMinor);
|
||||||
|
expect(s.interestCategoryId).not.toBeNull();
|
||||||
|
expect(s.interestBooked).toBe(false);
|
||||||
|
expect((await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === id)!.balanceMinor).toBe(
|
||||||
|
-3_000_00,
|
||||||
|
);
|
||||||
|
expect(await getOverdraftSummary(ayse, cakir, await acc("Cüzdan"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CardStatementModel } from "@/models/card-statement";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import {
|
||||||
|
cardPeriods,
|
||||||
|
closingOnOrAfter,
|
||||||
|
dueDate,
|
||||||
|
estimateOverdraftInterest,
|
||||||
|
minimumPayment,
|
||||||
|
minimumRateBp,
|
||||||
|
nextPeriod,
|
||||||
|
previousPeriod,
|
||||||
|
type DueRule,
|
||||||
|
type Period,
|
||||||
|
} from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { loadContext, toItem, userNames, type LeanTx, type TransactionListItem } from "./service";
|
||||||
|
import { accountMatch, balanceBefore } from "./statement";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kredi kartı özeti ve KMH dönem özeti (Bölüm 6.8). Tüm tutarlar işlemlerden hesaplanır;
|
||||||
|
* dönem kayıtları (`statements`) okunurken güncellenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const HISTORY = 6;
|
||||||
|
const dayStart = (ymd: string) => istanbulDayStart(ymd)!;
|
||||||
|
const endExclusive = (ymd: string) => new Date(dayStart(ymd).getTime() + DAY_MS);
|
||||||
|
|
||||||
|
type AccountDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
householdId: Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
openingBalanceMinor: number;
|
||||||
|
archivedAt?: Date | null;
|
||||||
|
creditCard?: {
|
||||||
|
limitMinor: number;
|
||||||
|
statementDay: number;
|
||||||
|
dueRule: DueRule;
|
||||||
|
paymentAccountId?: Types.ObjectId | null;
|
||||||
|
minimumPaymentRateBp?: number | null;
|
||||||
|
} | null;
|
||||||
|
overdraft?: {
|
||||||
|
enabled: boolean;
|
||||||
|
limitMinor: number;
|
||||||
|
monthlyRateBp?: number | null;
|
||||||
|
statementDay?: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hesaba giriş (gelir, gelen transfer) ve çıkış (gider, giden transfer) toplamları. */
|
||||||
|
async function flows(householdId: Types.ObjectId, accountId: Types.ObjectId, from: Date, to: Date) {
|
||||||
|
const pipeline: PipelineStage[] = [
|
||||||
|
{ $match: { ...accountMatch(householdId, accountId), date: { $gte: from, $lt: to } } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
inflow: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ $and: [{ $eq: ["$type", "income"] }, { $eq: ["$accountId", accountId] }] },
|
||||||
|
{ $and: [{ $eq: ["$type", "transfer"] }, { $eq: ["$toAccountId", accountId] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"$amountMinor",
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outflow: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{ $in: ["$type", ["expense", "transfer"]] },
|
||||||
|
{ $eq: ["$accountId", accountId] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"$amountMinor",
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const [row] = await TransactionModel.aggregate<{ inflow: number; outflow: number }>(pipeline);
|
||||||
|
return { inflow: row?.inflow ?? 0, outflow: row?.outflow ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccount(userId: string, householdId: string, accountId: string) {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const info = ctx.accountMap.get(accountId);
|
||||||
|
if (!info || info.hidden) return null;
|
||||||
|
const account = await AccountModel.findOne({
|
||||||
|
_id: info.id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
}).lean<AccountDoc>();
|
||||||
|
return account ? { ctx, account } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kredi kartı ---
|
||||||
|
|
||||||
|
export type StatementSummary = {
|
||||||
|
period: Period;
|
||||||
|
dueDate: string;
|
||||||
|
debtMinor: number;
|
||||||
|
minimumMinor: number;
|
||||||
|
paidMinor: number;
|
||||||
|
spentMinor: number;
|
||||||
|
remainingMinor: number;
|
||||||
|
minimumRemainingMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardSummary = {
|
||||||
|
limitMinor: number;
|
||||||
|
debtMinor: number;
|
||||||
|
availableMinor: number;
|
||||||
|
/** Henüz gerçekleşmemiş taksitlerin toplamı (limitten düşer). */
|
||||||
|
pendingInstallmentsMinor: number;
|
||||||
|
minimumRateBp: number;
|
||||||
|
current: Period & { spentMinor: number };
|
||||||
|
statement: StatementSummary;
|
||||||
|
daysToDue: number;
|
||||||
|
history: StatementSummary[];
|
||||||
|
/** Dönem dönem yaklaşan taksit toplamları. */
|
||||||
|
upcomingInstallments: { periodEnd: string; dueDate: string; totalMinor: number; count: number }[];
|
||||||
|
/** Güncel ve sonraki dönemdeki planlı (taksit dışı) harcamalar. */
|
||||||
|
planned: TransactionListItem[];
|
||||||
|
paymentAccount: { id: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCardSummary(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<CardSummary | null> {
|
||||||
|
const loaded = await loadAccount(userId, householdId, accountId);
|
||||||
|
if (!loaded || loaded.account.type !== "credit_card" || !loaded.account.creditCard) return null;
|
||||||
|
const { ctx, account } = loaded;
|
||||||
|
const card = account.creditCard!;
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
const S = card.statementDay;
|
||||||
|
const rate = minimumRateBp(card.limitMinor, card.minimumPaymentRateBp);
|
||||||
|
const { current, statement } = cardPeriods(today, S);
|
||||||
|
|
||||||
|
const balanceNow = await balanceBefore(hh, account, null);
|
||||||
|
const debtMinor = Math.max(0, -balanceNow);
|
||||||
|
|
||||||
|
// Son ekstre + geçmiş dönemler (yeniden eskiye).
|
||||||
|
const periods: Period[] = [statement];
|
||||||
|
while (periods.length < HISTORY) periods.push(previousPeriod(periods.at(-1)!, S));
|
||||||
|
const history = await Promise.all(
|
||||||
|
periods.map(async (p): Promise<StatementSummary> => {
|
||||||
|
const next = nextPeriod(p, S);
|
||||||
|
const [debtAt, paidFlows, spentFlows] = await Promise.all([
|
||||||
|
balanceBefore(hh, account, endExclusive(p.end)),
|
||||||
|
flows(hh, account._id, endExclusive(p.end), endExclusive(next.end)),
|
||||||
|
flows(hh, account._id, dayStart(p.start), endExclusive(p.end)),
|
||||||
|
]);
|
||||||
|
const debt = Math.max(0, -debtAt);
|
||||||
|
const minimum = minimumPayment(debt, rate);
|
||||||
|
return {
|
||||||
|
period: p,
|
||||||
|
dueDate: dueDate(p.end, card.dueRule),
|
||||||
|
debtMinor: debt,
|
||||||
|
minimumMinor: minimum,
|
||||||
|
paidMinor: paidFlows.inflow,
|
||||||
|
spentMinor: spentFlows.outflow,
|
||||||
|
remainingMinor: Math.max(0, debt - paidFlows.inflow),
|
||||||
|
minimumRemainingMinor: Math.max(0, minimum - paidFlows.inflow),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
await CardStatementModel.bulkWrite(
|
||||||
|
history.map((h) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { accountId: account._id, periodEnd: h.period.end },
|
||||||
|
update: {
|
||||||
|
$set: {
|
||||||
|
householdId: hh,
|
||||||
|
kind: "credit_card",
|
||||||
|
periodStart: h.period.start,
|
||||||
|
dueDate: h.dueDate,
|
||||||
|
statementDebtMinor: h.debtMinor,
|
||||||
|
minimumPaymentMinor: h.minimumMinor,
|
||||||
|
paidMinor: h.paidMinor,
|
||||||
|
spentMinor: h.spentMinor,
|
||||||
|
computedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFlows = await flows(
|
||||||
|
hh,
|
||||||
|
account._id,
|
||||||
|
dayStart(current.start),
|
||||||
|
endExclusive(current.end),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Planlı taksitler (dönemlerine göre) ve diğer planlı harcamalar.
|
||||||
|
const planned = (await TransactionModel.find({
|
||||||
|
householdId: hh,
|
||||||
|
accountId: account._id,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.lean()) as unknown as LeanTx[];
|
||||||
|
const byPeriod = new Map<string, { totalMinor: number; count: number }>();
|
||||||
|
let pendingInstallmentsMinor = 0;
|
||||||
|
const horizon = nextPeriod(current, S).end;
|
||||||
|
const other: LeanTx[] = [];
|
||||||
|
for (const tx of planned) {
|
||||||
|
if (tx.installment?.planId) {
|
||||||
|
const end = closingOnOrAfter(toIstanbulYmd(tx.date), S);
|
||||||
|
const x = byPeriod.get(end) ?? { totalMinor: 0, count: 0 };
|
||||||
|
x.totalMinor += tx.amountMinor;
|
||||||
|
x.count++;
|
||||||
|
byPeriod.set(end, x);
|
||||||
|
pendingInstallmentsMinor += tx.amountMinor;
|
||||||
|
} else if (toIstanbulYmd(tx.date) <= horizon && tx.type !== "income") {
|
||||||
|
other.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const names = await userNames(other.map((t) => t.createdBy as Types.ObjectId));
|
||||||
|
const statementSummary = history[0]!;
|
||||||
|
const payer = card.paymentAccountId ? ctx.accountMap.get(String(card.paymentAccountId)) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limitMinor: card.limitMinor,
|
||||||
|
debtMinor,
|
||||||
|
availableMinor: card.limitMinor - debtMinor - pendingInstallmentsMinor,
|
||||||
|
pendingInstallmentsMinor,
|
||||||
|
minimumRateBp: rate,
|
||||||
|
current: { ...current, spentMinor: currentFlows.outflow },
|
||||||
|
statement: statementSummary,
|
||||||
|
daysToDue: Math.round(
|
||||||
|
(dayStart(statementSummary.dueDate).getTime() - dayStart(today).getTime()) / DAY_MS,
|
||||||
|
),
|
||||||
|
history,
|
||||||
|
upcomingInstallments: [...byPeriod.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(([periodEnd, x]) => ({ periodEnd, dueDate: dueDate(periodEnd, card.dueRule), ...x })),
|
||||||
|
planned: other.slice(0, 10).map((tx) => toItem(ctx, tx, names)),
|
||||||
|
paymentAccount:
|
||||||
|
payer && !payer.hidden && !payer.archived ? { id: String(payer.id), name: payer.name } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- KMH (esnek hesap) ---
|
||||||
|
|
||||||
|
export type OverdraftSummary = {
|
||||||
|
limitMinor: number;
|
||||||
|
balanceMinor: number;
|
||||||
|
usedMinor: number;
|
||||||
|
availableMinor: number;
|
||||||
|
monthlyRateBp: number;
|
||||||
|
current: Period & { interestSoFarMinor: number };
|
||||||
|
statement: Period & { interestMinor: number; averageUsedMinor: number };
|
||||||
|
/** Faiz kaydı önerisi için "Banka › Faiz" kategorisi (varsa). */
|
||||||
|
interestCategoryId: string | null;
|
||||||
|
/** Kesimden sonra bu hesapta faiz kategorisinde gider girilmiş mi? */
|
||||||
|
interestBooked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function dailyDeltas(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
accountId: Types.ObjectId,
|
||||||
|
period: Period,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const rows = await TransactionModel.aggregate<{ _id: string; delta: number }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...accountMatch(householdId, accountId),
|
||||||
|
date: { $gte: dayStart(period.start), $lt: endExclusive(period.end) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { format: "%Y-%m-%d", date: "$date", timezone: "Europe/Istanbul" } },
|
||||||
|
delta: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "transfer"] },
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$accountId", accountId] },
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
"$amountMinor",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$cond: [
|
||||||
|
{ $eq: ["$type", "income"] },
|
||||||
|
"$amountMinor",
|
||||||
|
{ $multiply: ["$amountMinor", -1] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [r._id, r.delta]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOverdraftSummary(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
accountId: string,
|
||||||
|
today = todayIstanbul(),
|
||||||
|
): Promise<OverdraftSummary | null> {
|
||||||
|
const loaded = await loadAccount(userId, householdId, accountId);
|
||||||
|
const od = loaded?.account.overdraft;
|
||||||
|
if (!loaded || loaded.account.type !== "bank" || !od?.enabled) return null;
|
||||||
|
const { ctx, account } = loaded;
|
||||||
|
const hh = ctx.m.householdId;
|
||||||
|
const S = od.statementDay ?? 1;
|
||||||
|
const rate = od.monthlyRateBp ?? 0;
|
||||||
|
const { current, statement } = cardPeriods(today, S);
|
||||||
|
|
||||||
|
const [balanceMinor, stStart, curStart, stDeltas, curDeltas, faiz] = await Promise.all([
|
||||||
|
balanceBefore(hh, account, null),
|
||||||
|
balanceBefore(hh, account, dayStart(statement.start)),
|
||||||
|
balanceBefore(hh, account, dayStart(current.start)),
|
||||||
|
dailyDeltas(hh, account._id, statement),
|
||||||
|
dailyDeltas(hh, account._id, current),
|
||||||
|
CategoryModel.findOne({ householdId: hh, defaultKey: "banka.faiz", archivedAt: null })
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean(),
|
||||||
|
]);
|
||||||
|
const st = estimateOverdraftInterest(statement, stStart, stDeltas, rate);
|
||||||
|
const cur = estimateOverdraftInterest(current, curStart, curDeltas, rate, today);
|
||||||
|
const interestBooked = faiz
|
||||||
|
? Boolean(
|
||||||
|
await TransactionModel.exists({
|
||||||
|
householdId: hh,
|
||||||
|
accountId: account._id,
|
||||||
|
type: "expense",
|
||||||
|
effectiveCategoryIds: faiz._id,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { $ne: "void" },
|
||||||
|
date: { $gte: dayStart(statement.end), $lt: endExclusive(addDays(today, 0)) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
await CardStatementModel.updateOne(
|
||||||
|
{ accountId: account._id, periodEnd: statement.end },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
householdId: hh,
|
||||||
|
kind: "overdraft",
|
||||||
|
periodStart: statement.start,
|
||||||
|
statementDebtMinor: Math.max(
|
||||||
|
0,
|
||||||
|
-(await balanceBefore(hh, account, endExclusive(statement.end))),
|
||||||
|
),
|
||||||
|
interestMinor: st.interestMinor,
|
||||||
|
computedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
limitMinor: od.limitMinor,
|
||||||
|
balanceMinor,
|
||||||
|
usedMinor: Math.max(0, -balanceMinor),
|
||||||
|
// Kullanılabilir KMH: limit − kullanılan (pozitif bakiye KMH'yi artırmaz).
|
||||||
|
availableMinor: Math.max(0, od.limitMinor - Math.max(0, -balanceMinor)),
|
||||||
|
monthlyRateBp: rate,
|
||||||
|
current: { ...current, interestSoFarMinor: cur.interestMinor },
|
||||||
|
statement: {
|
||||||
|
...statement,
|
||||||
|
interestMinor: st.interestMinor,
|
||||||
|
averageUsedMinor: st.averageUsedMinor,
|
||||||
|
},
|
||||||
|
interestCategoryId: faiz ? String(faiz._id) : null,
|
||||||
|
interestBooked,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, CreditCard, Landmark, Percent, Wallet } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuickAdd } from "@/components/layout/quick-add";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { Amount, rowTitle } from "./transaction-parts";
|
||||||
|
|
||||||
|
const c = tr.card;
|
||||||
|
const o = tr.overdraft;
|
||||||
|
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||||
|
const pct = (bp: number) => formatMoneyInput(bp).replace(/,00$/, "");
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("text-base font-semibold tabular-nums", tone)}>{value}</p>
|
||||||
|
{sub ? <p className="text-xs text-muted-foreground">{sub}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit kullanım çubuğu: borç + gelecek taksitler / limit. */
|
||||||
|
function UsageBar({ used, pending, limit }: { used: number; pending: number; limit: number }) {
|
||||||
|
if (limit <= 0) return null;
|
||||||
|
const u = Math.min(100, (used / limit) * 100);
|
||||||
|
const p = Math.min(100 - u, (pending / limit) * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${Math.round(u + p)}%`}
|
||||||
|
>
|
||||||
|
<div className="bg-expense" style={{ width: `${u}%` }} />
|
||||||
|
<div className="bg-expense/40" style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSummaryPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: CardSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const st = s.statement;
|
||||||
|
|
||||||
|
function pay() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "transfer",
|
||||||
|
prefill: {
|
||||||
|
accountId: s.paymentAccount?.id,
|
||||||
|
toAccountId: accountId,
|
||||||
|
amountMinor: st.remainingMinor || undefined,
|
||||||
|
description: c.payDescription,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CreditCard className="size-4" aria-hidden />
|
||||||
|
{c.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat label={c.debt} value={formatMoney(s.debtMinor)} tone="text-expense" />
|
||||||
|
<Stat
|
||||||
|
label={c.available}
|
||||||
|
value={formatMoney(s.availableMinor)}
|
||||||
|
sub={c.limitOf(formatMoney(s.limitMinor))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.debtMinor} pending={s.pendingInstallmentsMinor} limit={s.limitMinor} />
|
||||||
|
{s.pendingInstallmentsMinor ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.pendingInstallments(formatMoney(s.pendingInstallmentsMinor))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.statementPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(st.period.start), d(st.period.end))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Stat label={c.statementDebt} value={formatMoney(st.debtMinor)} />
|
||||||
|
<Stat label={c.minimum(pct(s.minimumRateBp))} value={formatMoney(st.minimumMinor)} />
|
||||||
|
<Stat label={c.paid} value={formatMoney(st.paidMinor)} tone="text-income" />
|
||||||
|
<Stat
|
||||||
|
label={c.remaining}
|
||||||
|
value={formatMoney(st.remainingMinor)}
|
||||||
|
tone={st.remainingMinor ? "text-expense" : "text-income"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
{c.dueDate}: <span className="font-medium">{d(st.dueDate)}</span>{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
st.remainingMinor && s.daysToDue < 0 ? "text-expense" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{st.remainingMinor ? `· ${c.daysLeft(s.daysToDue)}` : `· ${c.paidInFull}`}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{canWrite ? (
|
||||||
|
<Button size="sm" onClick={pay}>
|
||||||
|
<Wallet />
|
||||||
|
{c.pay}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{c.currentPeriod}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{c.periodRange(d(s.current.start), d(s.current.end))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Stat label={c.spent} value={formatMoney(s.current.spentMinor)} tone="text-expense" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.upcomingInstallments}</h3>
|
||||||
|
{s.upcomingInstallments.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.upcomingInstallments.map((x) => (
|
||||||
|
<li key={x.periodEnd} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{c.installmentPeriod(d(x.periodEnd), d(x.dueDate), x.count)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{formatMoney(x.totalMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{c.noInstallments}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{s.planned.length ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{c.planned}</h3>
|
||||||
|
<ul className="divide-y rounded-xl border text-sm">
|
||||||
|
{s.planned.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{d(p.date)} · {rowTitle(p)}
|
||||||
|
</span>
|
||||||
|
<Amount type={p.type} amountMinor={p.amountMinor} className="font-medium" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="-ml-2 w-fit">
|
||||||
|
<ChevronDown className={cn("transition-transform", historyOpen && "rotate-180")} />
|
||||||
|
{c.history}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-2">
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-muted-foreground">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="p-2 text-left font-normal">{c.historyHeaders.period}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.debt}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.minimum}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.paid}</th>
|
||||||
|
<th className="p-2 text-right font-normal">{c.historyHeaders.due}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y tabular-nums">
|
||||||
|
{s.history.map((h) => (
|
||||||
|
<tr key={h.period.end}>
|
||||||
|
<td className="p-2 whitespace-nowrap">
|
||||||
|
{c.periodRange(d(h.period.start), d(h.period.end))}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.debtMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.minimumMinor)}</td>
|
||||||
|
<td className="p-2 text-right">{formatMoney(h.paidMinor)}</td>
|
||||||
|
<td className="p-2 text-right whitespace-nowrap">{d(h.dueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverdraftPanel({
|
||||||
|
summary: s,
|
||||||
|
accountId,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
summary: OverdraftSummary;
|
||||||
|
accountId: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { openTransactionForm } = useQuickAdd();
|
||||||
|
const range = c.periodRange(d(s.statement.start), d(s.statement.end));
|
||||||
|
|
||||||
|
function bookInterest() {
|
||||||
|
openTransactionForm({
|
||||||
|
kind: "create",
|
||||||
|
type: "expense",
|
||||||
|
prefill: {
|
||||||
|
accountId,
|
||||||
|
amountMinor: s.statement.interestMinor || undefined,
|
||||||
|
categoryId: s.interestCategoryId ?? undefined,
|
||||||
|
date: s.statement.end,
|
||||||
|
description: o.interestDescription(range),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Landmark className="size-4" aria-hidden />
|
||||||
|
{o.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.limit}
|
||||||
|
value={formatMoney(s.limitMinor)}
|
||||||
|
sub={o.rate(pct(s.monthlyRateBp))}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={o.used}
|
||||||
|
value={formatMoney(s.usedMinor)}
|
||||||
|
tone={s.usedMinor ? "text-expense" : undefined}
|
||||||
|
/>
|
||||||
|
<Stat label={o.available} value={formatMoney(Math.max(0, s.availableMinor))} />
|
||||||
|
</div>
|
||||||
|
<UsageBar used={s.usedMinor} pending={0} limit={s.limitMinor} />
|
||||||
|
<section className="flex flex-col gap-2 rounded-xl border p-3">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{o.lastPeriod}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{range}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<Stat
|
||||||
|
label={o.estimatedInterest}
|
||||||
|
value={formatMoney(s.statement.interestMinor)}
|
||||||
|
tone={s.statement.interestMinor ? "text-expense" : undefined}
|
||||||
|
sub={o.averageUsed(formatMoney(s.statement.averageUsedMinor))}
|
||||||
|
/>
|
||||||
|
{s.interestBooked ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{o.booked}</p>
|
||||||
|
) : canWrite && s.statement.interestMinor > 0 ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={bookInterest}>
|
||||||
|
<Percent />
|
||||||
|
{o.bookInterest}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{o.taxNote}</p>
|
||||||
|
</section>
|
||||||
|
<p className="text-sm">
|
||||||
|
{o.currentSoFar}:{" "}
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatMoney(s.current.interestSoFarMinor)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import { todayIstanbul } from "@/shared/dates";
|
|||||||
import { formatDate, formatMoney } from "@/shared/format";
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
|
import { previewReconciliationAction, reconcileAccountAction } from "../actions";
|
||||||
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
|
import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement";
|
||||||
|
import type { CardSummary, OverdraftSummary } from "../card";
|
||||||
|
import { CardSummaryPanel, OverdraftPanel } from "./card-summary";
|
||||||
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
|
import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts";
|
||||||
|
|
||||||
const t = tr.statement;
|
const t = tr.statement;
|
||||||
@@ -57,9 +59,13 @@ function SignedMoney({ minor, className }: { minor: number; className?: string }
|
|||||||
|
|
||||||
export function StatementView({
|
export function StatementView({
|
||||||
statement: s,
|
statement: s,
|
||||||
|
card,
|
||||||
|
overdraft,
|
||||||
canWrite,
|
canWrite,
|
||||||
}: {
|
}: {
|
||||||
statement: AccountStatement;
|
statement: AccountStatement;
|
||||||
|
card?: CardSummary | null;
|
||||||
|
overdraft?: OverdraftSummary | null;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
const { openDetail, element: detailElement } = useTransactionDetail(canWrite);
|
||||||
@@ -95,6 +101,13 @@ export function StatementView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{card ? (
|
||||||
|
<CardSummaryPanel summary={card} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
{overdraft ? (
|
||||||
|
<OverdraftPanel summary={overdraft} accountId={s.account.id} canWrite={canWrite} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
|
<Button variant="outline" size="icon-sm" asChild aria-label={t.prevMonth}>
|
||||||
<Link href={`${base}?ay=${s.prevMonth}`}>
|
<Link href={`${base}?ay=${s.prevMonth}`}>
|
||||||
|
|||||||
@@ -42,9 +42,22 @@ import type { PayeeSuggestion, TransactionDetail, TransactionFormOptions } from
|
|||||||
|
|
||||||
const t = tr.transactions;
|
const t = tr.transactions;
|
||||||
const f = t.fields;
|
const f = t.fields;
|
||||||
|
const INSTALLMENT_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 18, 24, 36];
|
||||||
|
|
||||||
export type TransactionFormMode =
|
export type TransactionFormMode =
|
||||||
| { kind: "create"; type: TransactionType }
|
| {
|
||||||
|
kind: "create";
|
||||||
|
type: TransactionType;
|
||||||
|
/** Ön doldurma (ör. kart borcu ödemesi, KMH faiz kaydı). */
|
||||||
|
prefill?: {
|
||||||
|
accountId?: string;
|
||||||
|
toAccountId?: string;
|
||||||
|
amountMinor?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
date?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
| { kind: "edit"; transaction: TransactionDetail }
|
| { kind: "edit"; transaction: TransactionDetail }
|
||||||
| { kind: "copy"; transaction: TransactionDetail };
|
| { kind: "copy"; transaction: TransactionDetail };
|
||||||
|
|
||||||
@@ -62,22 +75,26 @@ type FormValues = {
|
|||||||
tags: string;
|
tags: string;
|
||||||
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
|
/** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */
|
||||||
attachments: AttachmentInfo[];
|
attachments: AttachmentInfo[];
|
||||||
|
/** Taksit sayısı (yalnızca yeni kredi kartı gideri). */
|
||||||
|
installments: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
|
function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues {
|
||||||
if (mode.kind === "create") {
|
if (mode.kind === "create") {
|
||||||
|
const p = mode.prefill ?? {};
|
||||||
return {
|
return {
|
||||||
type: mode.type,
|
type: mode.type,
|
||||||
amount: "",
|
amount: p.amountMinor ? formatMoneyInput(p.amountMinor) : "",
|
||||||
date: todayIstanbul(),
|
date: p.date ?? todayIstanbul(),
|
||||||
accountId: options?.accounts[0]?.id ?? "",
|
accountId: p.accountId ?? options?.accounts[0]?.id ?? "",
|
||||||
toAccountId: "",
|
toAccountId: p.toAccountId ?? "",
|
||||||
categoryId: "",
|
categoryId: p.categoryId ?? "",
|
||||||
splits: [],
|
splits: [],
|
||||||
payee: "",
|
payee: "",
|
||||||
description: "",
|
description: p.description ?? "",
|
||||||
tags: "",
|
tags: "",
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
installments: "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const x = mode.transaction;
|
const x = mode.transaction;
|
||||||
@@ -98,6 +115,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
tags: x.tags.join(", "),
|
tags: x.tags.join(", "),
|
||||||
// Kopyada ekler taşınmaz (fiş o işleme aittir).
|
// Kopyada ekler taşınmaz (fiş o işleme aittir).
|
||||||
attachments: mode.kind === "edit" ? x.attachments : [],
|
attachments: mode.kind === "edit" ? x.attachments : [],
|
||||||
|
installments: "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +392,13 @@ export function TransactionFormDialog({
|
|||||||
}, [options, type, mode]);
|
}, [options, type, mode]);
|
||||||
|
|
||||||
const totalMinor = parseMoney(amount ?? "") ?? 0;
|
const totalMinor = parseMoney(amount ?? "") ?? 0;
|
||||||
|
const selectedAccountType = options?.accounts.find((a) => a.id === accountId)?.type;
|
||||||
|
// Taksit: yeni (veya kopya) kredi kartı gideri, bölünmemiş.
|
||||||
|
const showInstallments =
|
||||||
|
mode?.kind !== "edit" &&
|
||||||
|
type === "expense" &&
|
||||||
|
selectedAccountType === "credit_card" &&
|
||||||
|
!splitMode;
|
||||||
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
|
const splitSum = (splitValues ?? []).reduce((a, s) => a + (parseMoney(s.amount ?? "") ?? 0), 0);
|
||||||
const remaining = totalMinor - splitSum;
|
const remaining = totalMinor - splitSum;
|
||||||
|
|
||||||
@@ -448,6 +473,7 @@ export function TransactionFormDialog({
|
|||||||
const { attachments, ...values } = form.getValues();
|
const { attachments, ...values } = form.getValues();
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
|
installments: showInstallments ? values.installments : "1",
|
||||||
attachmentIds: attachments.map((a) => a.id),
|
attachmentIds: attachments.map((a) => a.id),
|
||||||
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
toAccountId: values.type === "transfer" ? values.toAccountId : "",
|
||||||
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
|
categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId,
|
||||||
@@ -595,6 +621,42 @@ export function TransactionFormDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{showInstallments ? (
|
||||||
|
<Controller
|
||||||
|
name="installments"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="tx-installments">{f.installments}</FieldLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger id="tx-installments" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{field.value === "1"
|
||||||
|
? f.singlePayment
|
||||||
|
: f.installmentCount(Number(field.value))}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{INSTALLMENT_OPTIONS.map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n === 1 ? f.singlePayment : f.installmentCount(n)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{Number(field.value) > 1 && totalMinor > 0 ? (
|
||||||
|
<FieldDescription>
|
||||||
|
{f.installmentHint(
|
||||||
|
Number(field.value),
|
||||||
|
formatMoney(Math.ceil(totalMinor / Number(field.value))),
|
||||||
|
)}
|
||||||
|
</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{type === "transfer" ? (
|
{type === "transfer" ? (
|
||||||
<Controller
|
<Controller
|
||||||
name="toAccountId"
|
name="toAccountId"
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import { tr } from "@/i18n/tr";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
|
import { formatDate, formatDateTime, formatMoney } from "@/shared/format";
|
||||||
import type { TransactionType } from "@/shared/transactions";
|
import type { TransactionType } from "@/shared/transactions";
|
||||||
import { getTransactionAction, setTransactionDeletedAction } from "../actions";
|
import {
|
||||||
|
cancelRemainingInstallmentsAction,
|
||||||
|
getTransactionAction,
|
||||||
|
setTransactionDeletedAction,
|
||||||
|
} from "../actions";
|
||||||
import type { TransactionDetail, TransactionListItem } from "../service";
|
import type { TransactionDetail, TransactionListItem } from "../service";
|
||||||
|
|
||||||
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
|
/** İşlem listesi, ayrıntı ve hesap ekstresinin paylaştığı parçalar. */
|
||||||
@@ -101,6 +105,12 @@ export function RowIcon({ item }: { item: TransactionListItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function rowTitle(item: TransactionListItem) {
|
export function rowTitle(item: TransactionListItem) {
|
||||||
|
const base = baseTitle(item);
|
||||||
|
// Taksit: "Hepsiburada 4/9"
|
||||||
|
return item.installment ? `${base} ${item.installment.index}/${item.installment.count}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseTitle(item: TransactionListItem) {
|
||||||
if (item.isAdjustment) return t.adjustment;
|
if (item.isAdjustment) return t.adjustment;
|
||||||
if (item.payeeName) return item.payeeName;
|
if (item.payeeName) return item.payeeName;
|
||||||
if (item.description) return item.description;
|
if (item.description) return item.description;
|
||||||
@@ -127,6 +137,7 @@ function DetailDialog({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onCancelInstallments,
|
||||||
}: {
|
}: {
|
||||||
detail: TransactionDetail | null;
|
detail: TransactionDetail | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -135,6 +146,7 @@ function DetailDialog({
|
|||||||
onEdit: (d: TransactionDetail) => void;
|
onEdit: (d: TransactionDetail) => void;
|
||||||
onCopy: (d: TransactionDetail) => void;
|
onCopy: (d: TransactionDetail) => void;
|
||||||
onDelete: (d: TransactionDetail) => void;
|
onDelete: (d: TransactionDetail) => void;
|
||||||
|
onCancelInstallments: (d: TransactionDetail) => void;
|
||||||
}) {
|
}) {
|
||||||
const d = t.detail;
|
const d = t.detail;
|
||||||
const row = (label: string, value: React.ReactNode) => (
|
const row = (label: string, value: React.ReactNode) => (
|
||||||
@@ -219,6 +231,27 @@ function DetailDialog({
|
|||||||
</span>,
|
</span>,
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
{detail.installment
|
||||||
|
? row(
|
||||||
|
t.fields.installments,
|
||||||
|
<span className="flex flex-col items-end gap-1">
|
||||||
|
<span>
|
||||||
|
{detail.installment.index}/{detail.installment.count}
|
||||||
|
</span>
|
||||||
|
{canWrite &&
|
||||||
|
(detail.status === "planned" ||
|
||||||
|
detail.installment.index < detail.installment.count) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onCancelInstallments(detail)}
|
||||||
|
>
|
||||||
|
{t.cancelInstallments}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{detail.reconciledAccountIds.length
|
{detail.reconciledAccountIds.length
|
||||||
? row(
|
? row(
|
||||||
tr.statement.reconcile.title,
|
tr.statement.reconcile.title,
|
||||||
@@ -323,6 +356,14 @@ export function useTransactionDetail(canWrite: boolean) {
|
|||||||
openTransactionForm({ kind: "copy", transaction: d });
|
openTransactionForm({ kind: "copy", transaction: d });
|
||||||
}}
|
}}
|
||||||
onDelete={setDeleting}
|
onDelete={setDeleting}
|
||||||
|
onCancelInstallments={(d) => {
|
||||||
|
close();
|
||||||
|
startTransition(async () => {
|
||||||
|
const r = await cancelRemainingInstallmentsAction(d.id);
|
||||||
|
if (r.ok) toast.success(t.toast.installmentsCancelled(r.data.cancelled));
|
||||||
|
else toast.error(r.error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
|
<AlertDialog open={Boolean(deleting)} onOpenChange={(o) => !o && setDeleting(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function form(over: Partial<TransactionForm>): TransactionForm {
|
|||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,7 @@ describe("bakiye tutarlılığı", () => {
|
|||||||
cardDueKind: "daysAfter" as const,
|
cardDueKind: "daysAfter" as const,
|
||||||
cardDueValue: 10,
|
cardDueValue: 10,
|
||||||
cardPaymentAccountId: null,
|
cardPaymentAccountId: null,
|
||||||
|
cardMinimumRate: null,
|
||||||
};
|
};
|
||||||
const b = await createAccount(ids.user("ayse"), cakir, {
|
const b = await createAccount(ids.user("ayse"), cakir, {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { TransactionModel } from "@/models/transaction";
|
|||||||
import type { AccountType } from "@/shared/accounts";
|
import type { AccountType } from "@/shared/accounts";
|
||||||
import { allowedAttachmentKinds } from "@/shared/attachments";
|
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates";
|
import { installmentSchedule } from "@/shared/card-periods";
|
||||||
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
import type { CategoryKind } from "@/shared/schemas/category";
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -66,6 +67,7 @@ export async function loadContext(
|
|||||||
) {
|
) {
|
||||||
const m = await resolveMembership(userId, householdId, minRole);
|
const m = await resolveMembership(userId, householdId, minRole);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
|
await postDueInstallments(m.householdId);
|
||||||
const [accounts, categories] = await Promise.all([
|
const [accounts, categories] = await Promise.all([
|
||||||
AccountModel.find({ householdId: m.householdId })
|
AccountModel.find({ householdId: m.householdId })
|
||||||
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
||||||
@@ -145,9 +147,28 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
|||||||
* Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler.
|
* Hesap bazında işlem etkileri toplamı (kuruş): yalnızca posted ve silinmemiş işlemler.
|
||||||
* transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +.
|
* transactionEffects ile aynı kural: gelir +, gider −, transfer kaynak − / hedef +.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Vadesi gelen taksitleri gerçekleştirir (planlı → posted). Okuma yollarında çağrılır; zamanlanmış
|
||||||
|
* görev gerekmez. Taksitler onay beklemez (Faz 7 kararı: kendi döneminde otomatik).
|
||||||
|
*/
|
||||||
|
export async function postDueInstallments(householdId: Types.ObjectId, today = todayIstanbul()) {
|
||||||
|
const tomorrow = istanbulDayStart(today)!.getTime() + DAY_MS;
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{
|
||||||
|
householdId,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
"installment.planId": { $type: "objectId" },
|
||||||
|
date: { $lt: new Date(tomorrow) },
|
||||||
|
},
|
||||||
|
{ $set: { status: "posted" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function computeAccountDeltas(
|
export async function computeAccountDeltas(
|
||||||
householdId: Types.ObjectId,
|
householdId: Types.ObjectId,
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Map<string, number>> {
|
||||||
|
await postDueInstallments(householdId);
|
||||||
const pipeline: PipelineStage[] = [
|
const pipeline: PipelineStage[] = [
|
||||||
{ $match: { householdId, status: "posted", deletedAt: null } },
|
{ $match: { householdId, status: "posted", deletedAt: null } },
|
||||||
{
|
{
|
||||||
@@ -318,6 +339,11 @@ export async function createTransaction(
|
|||||||
const built = buildDoc(ctx, input);
|
const built = buildDoc(ctx, input);
|
||||||
if (!built.ok) return built;
|
if (!built.ok) return built;
|
||||||
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
|
const d = built.data as { accountId: Types.ObjectId; categoryId: Types.ObjectId | null };
|
||||||
|
if (input.installments > 1 && ctx.accountMap.get(String(d.accountId))?.type !== "credit_card") {
|
||||||
|
return fail(t.validation.installmentsCardOnly, {
|
||||||
|
installments: t.validation.installmentsCardOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
// Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar.
|
// Bölünmüş işlemde tek bir kategori önerilemez; satıcı kuralı yalnızca hesabı hatırlar.
|
||||||
const payeeId = await touchPayee(
|
const payeeId = await touchPayee(
|
||||||
ctx.m.householdId,
|
ctx.m.householdId,
|
||||||
@@ -330,21 +356,33 @@ export async function createTransaction(
|
|||||||
try {
|
try {
|
||||||
let id = "";
|
let id = "";
|
||||||
await mongoose.connection.transaction(async (session) => {
|
await mongoose.connection.transaction(async (session) => {
|
||||||
const [doc] = await TransactionModel.create(
|
const base = {
|
||||||
[
|
...built.data,
|
||||||
{
|
householdId: ctx.m.householdId,
|
||||||
...built.data,
|
status: "posted",
|
||||||
householdId: ctx.m.householdId,
|
payeeId,
|
||||||
status: "posted",
|
createdBy: actor,
|
||||||
payeeId,
|
updatedBy: actor,
|
||||||
createdBy: actor,
|
};
|
||||||
updatedBy: actor,
|
// Taksitli: her taksit ayrı işlem; vadesi gelmeyenler planlı (kendi döneminde gerçekleşir).
|
||||||
},
|
const today = todayIstanbul();
|
||||||
],
|
const planId = new Types.ObjectId();
|
||||||
{ session },
|
const docs =
|
||||||
);
|
input.installments > 1
|
||||||
id = String(doc!._id);
|
? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({
|
||||||
await attach(ctx, actorId, doc!._id, input, session);
|
...base,
|
||||||
|
amountMinor: x.amountMinor,
|
||||||
|
date: istanbulDayStart(x.date)!,
|
||||||
|
status: x.date <= today ? "posted" : "planned",
|
||||||
|
installment: { planId, index: x.index + 1, count: input.installments },
|
||||||
|
}))
|
||||||
|
: [base];
|
||||||
|
const created = await TransactionModel.insertMany(docs as Record<string, unknown>[], {
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
const first = created[0]!;
|
||||||
|
id = String(first._id);
|
||||||
|
await attach(ctx, actorId, first._id, input, session);
|
||||||
});
|
});
|
||||||
return ok({ id });
|
return ok({ id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -486,6 +524,41 @@ export async function setTransactionDeleted(
|
|||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalan (henüz gerçekleşmemiş) taksitleri iptal eder — ör. ürün iadesi veya erken kapama.
|
||||||
|
* Gerçekleşmiş taksitlere dokunulmaz.
|
||||||
|
*/
|
||||||
|
export async function cancelRemainingInstallments(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
transactionId: string,
|
||||||
|
): Promise<ActionResult<{ cancelled: number }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const tx = await loadOwnTransaction(ctx, transactionId);
|
||||||
|
const planId = tx?.installment?.planId;
|
||||||
|
if (!tx || !planId) return fail(tr.common.notFound);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const res = await TransactionModel.updateMany(
|
||||||
|
{
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
"installment.planId": planId,
|
||||||
|
status: "planned",
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||||
|
);
|
||||||
|
await writeAudit({
|
||||||
|
action: "transaction.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "installmentPlan",
|
||||||
|
targetId: String(planId),
|
||||||
|
diff: { cancelled: res.modifiedCount, count: tx.installment?.count ?? null },
|
||||||
|
});
|
||||||
|
return ok({ cancelled: res.modifiedCount });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Okuma ---
|
// --- Okuma ---
|
||||||
|
|
||||||
export type TransactionListItem = {
|
export type TransactionListItem = {
|
||||||
@@ -507,6 +580,8 @@ export type TransactionListItem = {
|
|||||||
isAdjustment: boolean;
|
isAdjustment: boolean;
|
||||||
/** Mutabakatı yapılmış hesaplar. */
|
/** Mutabakatı yapılmış hesaplar. */
|
||||||
reconciledAccountIds: string[];
|
reconciledAccountIds: string[];
|
||||||
|
/** Taksit: "4/9" */
|
||||||
|
installment: { planId: string; index: number; count: number } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionDetail = TransactionListItem & {
|
export type TransactionDetail = TransactionListItem & {
|
||||||
@@ -567,6 +642,13 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
|||||||
attachmentCount: tx.attachmentCount ?? 0,
|
attachmentCount: tx.attachmentCount ?? 0,
|
||||||
isAdjustment: Boolean(tx.isAdjustment),
|
isAdjustment: Boolean(tx.isAdjustment),
|
||||||
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
|
reconciledAccountIds: (tx.reconciledAccountIds ?? []).map(String),
|
||||||
|
installment: tx.installment?.planId
|
||||||
|
? {
|
||||||
|
planId: String(tx.installment.planId),
|
||||||
|
index: tx.installment.index ?? 0,
|
||||||
|
count: tx.installment.count ?? 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe("hesap ekstresi", () => {
|
|||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
} satisfies TransactionForm);
|
} satisfies TransactionForm);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
|
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
|
||||||
@@ -172,6 +173,7 @@ describe("mutabakat", () => {
|
|||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [],
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
});
|
});
|
||||||
expect(edit.ok).toBe(false);
|
expect(edit.ok).toBe(false);
|
||||||
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
|
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
|
||||||
@@ -215,6 +217,7 @@ describe("mutabakat", () => {
|
|||||||
description: "yeni açıklama",
|
description: "yeni açıklama",
|
||||||
tags: d.tags,
|
tags: d.tags,
|
||||||
attachmentIds: [],
|
attachmentIds: [],
|
||||||
|
installments: 1,
|
||||||
};
|
};
|
||||||
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
|
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|||||||
const MAX_ROWS = 2000;
|
const MAX_ROWS = 2000;
|
||||||
|
|
||||||
/** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */
|
/** Hesap bazında bakiyeyi etkileyen işlemler (posted, silinmemiş). */
|
||||||
function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
|
export function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
status: "posted" as const,
|
status: "posted" as const,
|
||||||
@@ -46,7 +46,7 @@ function accountMatch(householdId: Types.ObjectId, accountId: Types.ObjectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */
|
/** Açılış bakiyesi + (isteğe bağlı tarihten önceki) işlem etkileri. */
|
||||||
async function balanceBefore(
|
export async function balanceBefore(
|
||||||
householdId: Types.ObjectId,
|
householdId: Types.ObjectId,
|
||||||
account: { _id: Types.ObjectId; openingBalanceMinor: number },
|
account: { _id: Types.ObjectId; openingBalanceMinor: number },
|
||||||
before: Date | null,
|
before: Date | null,
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export const tr = {
|
|||||||
cardDueValueDay: "Ayın günü",
|
cardDueValueDay: "Ayın günü",
|
||||||
cardPaymentAccount: "Ödeme hesabı",
|
cardPaymentAccount: "Ödeme hesabı",
|
||||||
cardPaymentAccountNone: "— Seçilmedi —",
|
cardPaymentAccountNone: "— Seçilmedi —",
|
||||||
|
cardMinimumRate: "Asgari ödeme oranı (%)",
|
||||||
|
cardMinimumRateHint:
|
||||||
|
"Boş bırakılırsa limit 50.000 TL'nin altındaysa %20, üstündeyse %40 kullanılır.",
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
assets: "Varlıklar",
|
assets: "Varlıklar",
|
||||||
@@ -266,6 +269,11 @@ export const tr = {
|
|||||||
remainingOk: "Kalemler toplamı tutara eşit",
|
remainingOk: "Kalemler toplamı tutara eşit",
|
||||||
fillRemaining: "Kalanı son kaleme ekle",
|
fillRemaining: "Kalanı son kaleme ekle",
|
||||||
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
|
suggested: "Bu satıcı için son kullanılan kategori ve hesap önerildi.",
|
||||||
|
installments: "Taksit",
|
||||||
|
singlePayment: "Peşin",
|
||||||
|
installmentCount: (n: number) => `${n} taksit`,
|
||||||
|
installmentHint: (n: number, each: string) =>
|
||||||
|
`${n} × yaklaşık ${each}; vadesi gelen taksit kendi döneminde otomatik gerçekleşir.`,
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
amountPositive: "Tutar sıfırdan büyük olmalı",
|
amountPositive: "Tutar sıfırdan büyük olmalı",
|
||||||
@@ -277,6 +285,9 @@ export const tr = {
|
|||||||
splitSum: "Kalemlerin toplamı tutara eşit olmalı",
|
splitSum: "Kalemlerin toplamı tutara eşit olmalı",
|
||||||
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
|
accountInvalid: "Hesap bu hanede yok, arşivli veya size kapalı.",
|
||||||
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
|
categoryInvalid: "Kategori bu hanede yok, arşivli veya işlem türüyle uyuşmuyor.",
|
||||||
|
installmentsMax: "En fazla 36 taksit",
|
||||||
|
installmentsExpenseOnly: "Taksit yalnızca bölünmemiş kredi kartı giderinde kullanılabilir.",
|
||||||
|
installmentsCardOnly: "Taksit yalnızca kredi kartı hesabında kullanılabilir.",
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
empty: "Bu filtrelerle işlem bulunamadı.",
|
empty: "Bu filtrelerle işlem bulunamadı.",
|
||||||
@@ -321,6 +332,7 @@ export const tr = {
|
|||||||
updated: "İşlem güncellendi.",
|
updated: "İşlem güncellendi.",
|
||||||
deleted: "İşlem silindi.",
|
deleted: "İşlem silindi.",
|
||||||
restored: "İşlem geri alındı.",
|
restored: "İşlem geri alındı.",
|
||||||
|
installmentsCancelled: (n: number) => `${n} kalan taksit iptal edildi.`,
|
||||||
},
|
},
|
||||||
deleteTitle: "İşlem silinsin mi?",
|
deleteTitle: "İşlem silinsin mi?",
|
||||||
deleteDescription:
|
deleteDescription:
|
||||||
@@ -348,6 +360,7 @@ export const tr = {
|
|||||||
adjustmentReadOnly:
|
adjustmentReadOnly:
|
||||||
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
"Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.",
|
||||||
reconciled: "Mutabakatı yapıldı",
|
reconciled: "Mutabakatı yapıldı",
|
||||||
|
cancelInstallments: "Kalan taksitleri iptal et",
|
||||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||||
noAccountsLink: "Hesaplara git",
|
noAccountsLink: "Hesaplara git",
|
||||||
@@ -460,6 +473,55 @@ export const tr = {
|
|||||||
upload: "Dosya yüklenemedi.",
|
upload: "Dosya yüklenemedi.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
card: {
|
||||||
|
title: "Kart özeti",
|
||||||
|
debt: "Toplam borç",
|
||||||
|
available: "Kullanılabilir limit",
|
||||||
|
limitOf: (limit: string) => `Limit ${limit}`,
|
||||||
|
pendingInstallments: (amount: string) => `Gelecek taksitler limitten düşer: ${amount}`,
|
||||||
|
statementPeriod: "Hesap kesim dönemi",
|
||||||
|
currentPeriod: "Güncel dönem",
|
||||||
|
periodRange: (start: string, end: string) => `${start} – ${end}`,
|
||||||
|
statementDebt: "Dönem borcu",
|
||||||
|
minimum: (rate: string) => `Asgari ödeme (%${rate})`,
|
||||||
|
paid: "Ödenen",
|
||||||
|
remaining: "Kalan",
|
||||||
|
dueDate: "Son ödeme",
|
||||||
|
daysLeft: (n: number) =>
|
||||||
|
n > 0 ? `${n} gün kaldı` : n === 0 ? "Bugün son gün" : `${-n} gün geçti`,
|
||||||
|
paidInFull: "Dönem borcu ödendi.",
|
||||||
|
spent: "Harcama",
|
||||||
|
pay: "Borcu öde",
|
||||||
|
payDescription: "Kart borcu ödemesi",
|
||||||
|
upcomingInstallments: "Yaklaşan taksitler",
|
||||||
|
installmentPeriod: (end: string, due: string, n: number) =>
|
||||||
|
`${end} kesim · son ödeme ${due} · ${n} taksit`,
|
||||||
|
noInstallments: "Bekleyen taksit yok.",
|
||||||
|
planned: "Planlı harcamalar",
|
||||||
|
history: "Geçmiş ekstreler",
|
||||||
|
historyHeaders: {
|
||||||
|
period: "Dönem",
|
||||||
|
debt: "Borç",
|
||||||
|
minimum: "Asgari",
|
||||||
|
paid: "Ödenen",
|
||||||
|
due: "Son ödeme",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overdraft: {
|
||||||
|
title: "Esnek hesap (KMH)",
|
||||||
|
limit: "KMH limiti",
|
||||||
|
used: "Kullanılan",
|
||||||
|
available: "Kullanılabilir KMH",
|
||||||
|
rate: (rate: string) => `Aylık faiz %${rate}`,
|
||||||
|
lastPeriod: "Geçen dönem",
|
||||||
|
currentSoFar: "Bu dönem (şimdiye kadar)",
|
||||||
|
estimatedInterest: "Tahmini faiz",
|
||||||
|
averageUsed: (amount: string) => `Ortalama kullanım ${amount}`,
|
||||||
|
bookInterest: "Faiz kaydı ekle",
|
||||||
|
booked: "Bu dönemin faiz kaydı girilmiş.",
|
||||||
|
interestDescription: (range: string) => `KMH faizi (${range})`,
|
||||||
|
taxNote: "Tahmin vergiler (KKDF/BSMV) hariçtir; banka ekstresindeki tutarı girin.",
|
||||||
|
},
|
||||||
statement: {
|
statement: {
|
||||||
title: "Hesap ekstresi",
|
title: "Hesap ekstresi",
|
||||||
back: "Hesaplar",
|
back: "Hesaplar",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ const creditCardSchema = new Schema(
|
|||||||
kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" },
|
kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" },
|
||||||
value: { type: Number, min: 1, max: 31, default: 10 },
|
value: { type: Number, min: 1, max: 31, default: 10 },
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Asgari ödeme oranı, baz puan (2000 = %20). null → varsayılan kural: limit 50.000 TL altı %20,
|
||||||
|
* üstü %40 (Faz 7 kararı).
|
||||||
|
*/
|
||||||
|
minimumPaymentRateBp: { type: Number, min: 0, max: 10_000, default: null },
|
||||||
/** Kart borcunun ödeneceği varsayılan hesap. */
|
/** Kart borcunun ödeneceği varsayılan hesap. */
|
||||||
paymentAccountId: { type: Schema.Types.ObjectId, default: null },
|
paymentAccountId: { type: Schema.Types.ObjectId, default: null },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kart/KMH dönem kaydı (Bölüm 6.8, `statements`). Tutarlar işlemlerden hesaplanır ve kart özeti
|
||||||
|
* okunurken güncellenir (geriye dönük girilen işlem dönem borcunu doğru değiştirir); bu koleksiyon
|
||||||
|
* raporlar ve mobil API için son hesaplanan değerleri tutar.
|
||||||
|
*/
|
||||||
|
const cardStatementSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
accountId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
kind: { type: String, enum: ["credit_card", "overdraft"], required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul), ikisi de dahil. */
|
||||||
|
periodStart: { type: String, required: true },
|
||||||
|
periodEnd: { type: String, required: true },
|
||||||
|
dueDate: { type: String, default: null },
|
||||||
|
/** Kesim günü sonundaki borç (pozitif). */
|
||||||
|
statementDebtMinor: { type: Number, default: 0 },
|
||||||
|
minimumPaymentMinor: { type: Number, default: 0 },
|
||||||
|
/** Kesimden sonra (bir sonraki kesime kadar) yapılan ödemeler. */
|
||||||
|
paidMinor: { type: Number, default: 0 },
|
||||||
|
/** Dönemdeki harcamalar (çıkışlar). */
|
||||||
|
spentMinor: { type: Number, default: 0 },
|
||||||
|
/** KMH: tahmini faiz. */
|
||||||
|
interestMinor: { type: Number, default: null },
|
||||||
|
computedAt: { type: Date, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "statements" },
|
||||||
|
);
|
||||||
|
|
||||||
|
cardStatementSchema.index({ accountId: 1, periodEnd: 1 }, { unique: true });
|
||||||
|
cardStatementSchema.index({ householdId: 1, accountId: 1, periodEnd: -1 });
|
||||||
|
|
||||||
|
export type CardStatement = InferSchemaType<typeof cardStatementSchema>;
|
||||||
|
|
||||||
|
export const CardStatementModel: Model<CardStatement> =
|
||||||
|
(models.CardStatement as Model<CardStatement> | undefined) ??
|
||||||
|
model<CardStatement>("CardStatement", cardStatementSchema);
|
||||||
@@ -76,6 +76,7 @@ transactionSchema.index({ householdId: 1, accountId: 1, date: -1 });
|
|||||||
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
|
transactionSchema.index({ householdId: 1, toAccountId: 1, date: -1 });
|
||||||
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
||||||
transactionSchema.index({ householdId: 1, tags: 1 });
|
transactionSchema.index({ householdId: 1, tags: 1 });
|
||||||
|
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||||
transactionSchema.index(
|
transactionSchema.index(
|
||||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||||
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cardPeriods,
|
||||||
|
closingOnOrAfter,
|
||||||
|
dueDate,
|
||||||
|
estimateOverdraftInterest,
|
||||||
|
installmentSchedule,
|
||||||
|
minimumPayment,
|
||||||
|
minimumRateBp,
|
||||||
|
nextPeriod,
|
||||||
|
periodContaining,
|
||||||
|
} from "./card-periods";
|
||||||
|
|
||||||
|
describe("dönem hesabı", () => {
|
||||||
|
it("kesim günü dahil hesap kesim dönemine girer; ertesi gün yeni dönem", () => {
|
||||||
|
expect(periodContaining("2026-10-29", 29)).toEqual({ start: "2026-09-30", end: "2026-10-29" });
|
||||||
|
expect(periodContaining("2026-10-30", 29)).toEqual({ start: "2026-10-30", end: "2026-11-29" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kesim günü ayda yoksa ayın son günü (31 → Şubat 28/29)", () => {
|
||||||
|
expect(closingOnOrAfter("2026-02-10", 31)).toBe("2026-02-28");
|
||||||
|
expect(periodContaining("2028-03-05", 31)).toEqual({ start: "2028-03-01", end: "2028-03-31" });
|
||||||
|
expect(periodContaining("2026-03-01", 30)).toEqual({ start: "2026-03-01", end: "2026-03-30" });
|
||||||
|
expect(periodContaining("2026-02-28", 30)).toEqual({ start: "2026-01-31", end: "2026-02-28" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yıl dönümü ve sonraki dönem", () => {
|
||||||
|
const p = periodContaining("2026-12-31", 15);
|
||||||
|
expect(p).toEqual({ start: "2026-12-16", end: "2027-01-15" });
|
||||||
|
expect(nextPeriod(p, 15)).toEqual({ start: "2027-01-16", end: "2027-02-15" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("güncel dönem ve son ekstre; kesim günü bugün iken dönem henüz kapanmamış", () => {
|
||||||
|
expect(cardPeriods("2026-11-05", 29)).toEqual({
|
||||||
|
current: { start: "2026-10-30", end: "2026-11-29" },
|
||||||
|
statement: { start: "2026-09-30", end: "2026-10-29" },
|
||||||
|
});
|
||||||
|
expect(cardPeriods("2026-11-29", 29).current.end).toBe("2026-11-29");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("son ödeme tarihi", () => {
|
||||||
|
expect(dueDate("2026-10-29", { kind: "daysAfter", value: 10 })).toBe("2026-11-08");
|
||||||
|
expect(dueDate("2026-10-29", { kind: "fixedDay", value: 9 })).toBe("2026-11-09");
|
||||||
|
expect(dueDate("2026-10-05", { kind: "fixedDay", value: 20 })).toBe("2026-10-20");
|
||||||
|
expect(dueDate("2026-01-29", { kind: "fixedDay", value: 31 })).toBe("2026-01-31");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asgari ödeme", () => {
|
||||||
|
it("limite göre %20 / %40; kart bazında oran öncelikli; yukarı yuvarlama", () => {
|
||||||
|
expect(minimumRateBp(49_999_99, null)).toBe(2000);
|
||||||
|
expect(minimumRateBp(50_000_00, null)).toBe(4000);
|
||||||
|
expect(minimumRateBp(60_000_00, 2500)).toBe(2500);
|
||||||
|
expect(minimumPayment(10_000_01, 2000)).toBe(2_000_01);
|
||||||
|
expect(minimumPayment(-5, 2000)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taksit planı", () => {
|
||||||
|
it("eşit bölme (kuruş farkı ilk taksitlerde), aylık tarihler, ay sonu", () => {
|
||||||
|
const s = installmentSchedule("2026-01-31", 10_000_00, 3);
|
||||||
|
expect(s.map((x) => x.amountMinor)).toEqual([3_333_34, 3_333_33, 3_333_33]);
|
||||||
|
expect(s.reduce((a, x) => a + x.amountMinor, 0)).toBe(10_000_00);
|
||||||
|
expect(s.map((x) => x.date)).toEqual(["2026-01-31", "2026-02-28", "2026-03-31"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KMH faiz tahmini", () => {
|
||||||
|
it("gün sonu eksi bakiyeler üzerinden", () => {
|
||||||
|
const period = { start: "2026-11-01", end: "2026-11-30" };
|
||||||
|
// 10 gün boyunca -3.000 TL; sonra sıfır.
|
||||||
|
const deltas = new Map([
|
||||||
|
["2026-11-01", -3_000_00],
|
||||||
|
["2026-11-11", 3_000_00],
|
||||||
|
]);
|
||||||
|
const r = estimateOverdraftInterest(period, 0, deltas, 425);
|
||||||
|
// 10 gün × 3.000 TL × %4,25 / 30 = 42,50 TL
|
||||||
|
expect(r.interestMinor).toBe(42_50);
|
||||||
|
expect(r.days).toBe(30);
|
||||||
|
expect(estimateOverdraftInterest(period, 5_000_00, deltas, 425).interestMinor).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { splitEvenly } from "./money";
|
||||||
|
import { addDays, addMonths, daysInMonth } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kredi kartı ve KMH dönemleri (Bölüm 6.8). Saf fonksiyonlar; tarihler "YYYY-MM-DD" (İstanbul).
|
||||||
|
*
|
||||||
|
* - Kesim günü ayda yoksa (ör. 31) ayın son günü kullanılır.
|
||||||
|
* - Kesim günü DAHİL o gün ve öncesi "Hesap Kesim Dönemi"ne girer (Bölüm 10 varsayılanı).
|
||||||
|
* - Kesim günü bitmeden dönem kapanmış sayılmaz: bugün kesim günüyse işlemler hâlâ "Güncel Dönem"de.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DueRule = { kind: "daysAfter" | "fixedDay"; value: number };
|
||||||
|
export type Period = { start: string; end: string };
|
||||||
|
|
||||||
|
/** Asgari ödeme varsayılanı (Faz 7 kararı): limit < 50.000 TL → %20, üstü → %40. */
|
||||||
|
export const MINIMUM_RATE_THRESHOLD_MINOR = 50_000_00;
|
||||||
|
export const MAX_INSTALLMENTS = 36;
|
||||||
|
|
||||||
|
/** O ayın kesim tarihi. */
|
||||||
|
export function closingDateIn(ym: string, statementDay: number): string {
|
||||||
|
const [y, m] = ym.split("-").map(Number) as [number, number];
|
||||||
|
const day = Math.min(statementDay, daysInMonth(y, m));
|
||||||
|
return `${ym}-${String(day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarihi içeren dönemin kesim tarihi (tarih kesim gününe eşitse o gün). */
|
||||||
|
export function closingOnOrAfter(ymd: string, statementDay: number): string {
|
||||||
|
const c = closingDateIn(ymd.slice(0, 7), statementDay);
|
||||||
|
return ymd <= c
|
||||||
|
? c
|
||||||
|
: closingDateIn(addMonths(`${ymd.slice(0, 7)}-01`, 1).slice(0, 7), statementDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarihi içeren dönem: önceki kesimin ertesi günü → bu kesim (dahil). */
|
||||||
|
export function periodContaining(ymd: string, statementDay: number): Period {
|
||||||
|
const end = closingOnOrAfter(ymd, statementDay);
|
||||||
|
const prevMonth = addMonths(`${end.slice(0, 7)}-01`, -1).slice(0, 7);
|
||||||
|
return { start: addDays(closingDateIn(prevMonth, statementDay), 1), end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousPeriod(p: Period, statementDay: number): Period {
|
||||||
|
return periodContaining(addDays(p.start, -1), statementDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextPeriod(p: Period, statementDay: number): Period {
|
||||||
|
return periodContaining(addDays(p.end, 1), statementDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Son ödeme tarihi: kesimden N gün sonra veya kesimden sonraki ilk "ayın N. günü". */
|
||||||
|
export function dueDate(closing: string, rule: DueRule): string {
|
||||||
|
if (rule.kind === "daysAfter") return addDays(closing, rule.value);
|
||||||
|
const same = addMonths(closing, 0, rule.value);
|
||||||
|
return same > closing ? same : addMonths(closing, 1, rule.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bugüne göre güncel dönem (bugünü içerir) ve son kesilmiş dönem (ekstre). */
|
||||||
|
export function cardPeriods(today: string, statementDay: number) {
|
||||||
|
const current = periodContaining(today, statementDay);
|
||||||
|
return { current, statement: previousPeriod(current, statementDay) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minimumRateBp(limitMinor: number, overrideBp: number | null | undefined): number {
|
||||||
|
if (overrideBp !== null && overrideBp !== undefined) return overrideBp;
|
||||||
|
return limitMinor < MINIMUM_RATE_THRESHOLD_MINOR ? 2000 : 4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asgari ödeme (kuruş, yukarı yuvarlanır). */
|
||||||
|
export function minimumPayment(debtMinor: number, rateBp: number): number {
|
||||||
|
if (debtMinor <= 0) return 0;
|
||||||
|
return Math.ceil((debtMinor * rateBp) / 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taksit planı: toplam eşit bölünür (kuruş farkı ilk taksitlere), her taksit alışveriş gününden
|
||||||
|
* itibaren aylık (ayda o gün yoksa son gün) — böylece her taksit ardışık bir döneme düşer.
|
||||||
|
*/
|
||||||
|
export function installmentSchedule(
|
||||||
|
purchaseYmd: string,
|
||||||
|
totalMinor: number,
|
||||||
|
count: number,
|
||||||
|
): { index: number; date: string; amountMinor: number }[] {
|
||||||
|
const day = Number(purchaseYmd.slice(8, 10));
|
||||||
|
return splitEvenly(totalMinor, count).map((amountMinor, index) => ({
|
||||||
|
index,
|
||||||
|
date: addMonths(purchaseYmd, index, day),
|
||||||
|
amountMinor,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KMH faizi tahmini: dönemdeki her günün gün sonu eksi bakiyesi × (aylık oran / 30).
|
||||||
|
* `startBalance`: dönem başından önceki bakiye; `deltas`: gün → o günün net hareketi.
|
||||||
|
* Vergiler (KKDF/BSMV) dahil değildir; kullanıcı kesim gününde kesinleştirir.
|
||||||
|
*/
|
||||||
|
export function estimateOverdraftInterest(
|
||||||
|
period: Period,
|
||||||
|
startBalanceMinor: number,
|
||||||
|
deltas: ReadonlyMap<string, number>,
|
||||||
|
monthlyRateBp: number,
|
||||||
|
until?: string,
|
||||||
|
): { interestMinor: number; averageUsedMinor: number; days: number } {
|
||||||
|
let balance = startBalanceMinor;
|
||||||
|
let used = 0;
|
||||||
|
let days = 0;
|
||||||
|
const last = until && until < period.end ? until : period.end;
|
||||||
|
for (let d = period.start; d <= last; d = addDays(d, 1)) {
|
||||||
|
balance += deltas.get(d) ?? 0;
|
||||||
|
used += Math.max(0, -balance);
|
||||||
|
days++;
|
||||||
|
}
|
||||||
|
const interestMinor = Math.round((used * monthlyRateBp) / 10_000 / 30);
|
||||||
|
return { interestMinor, averageUsedMinor: days ? Math.round(used / days) : 0, days };
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ export const accountFormSchema = z
|
|||||||
cardDueKind: z.enum(DUE_RULE_KINDS),
|
cardDueKind: z.enum(DUE_RULE_KINDS),
|
||||||
cardDueValue: day(31),
|
cardDueValue: day(31),
|
||||||
cardPaymentAccountId: objectIdOrEmpty,
|
cardPaymentAccountId: objectIdOrEmpty,
|
||||||
|
/** Boş → varsayılan kural (limite göre %20 / %40). */
|
||||||
|
cardMinimumRate: z.string().default("").pipe(percentToBp),
|
||||||
})
|
})
|
||||||
.superRefine((a, ctx) => {
|
.superRefine((a, ctx) => {
|
||||||
if (a.type === "meal_card" && !a.mealCardProvider) {
|
if (a.type === "meal_card" && !a.mealCardProvider) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
import { parseMoney } from "../money";
|
import { parseMoney } from "../money";
|
||||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||||
|
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||||
|
|
||||||
const v = tr.validation;
|
const v = tr.validation;
|
||||||
@@ -53,6 +54,14 @@ export const transactionFormSchema = z
|
|||||||
.union([z.string(), z.array(z.string())])
|
.union([z.string(), z.array(z.string())])
|
||||||
.default("")
|
.default("")
|
||||||
.transform((x) => normalizeTags(x)),
|
.transform((x) => normalizeTags(x)),
|
||||||
|
/**
|
||||||
|
* Taksit sayısı (yalnızca kredi kartı gideri, oluştururken). 1 = peşin. Her taksit ayrı işlem
|
||||||
|
* olur; vadesi gelmeyenler planlıdır ve kendi döneminde otomatik gerçekleşir.
|
||||||
|
*/
|
||||||
|
installments: z.preprocess(
|
||||||
|
(v) => (v === "" || v === null || v === undefined ? 1 : v),
|
||||||
|
z.coerce.number().int().min(1).max(MAX_INSTALLMENTS, t.installmentsMax),
|
||||||
|
),
|
||||||
/** Bağlı ek dosyalar (sıralı). Yeni ekler önce /api/files ile yüklenir. */
|
/** Bağlı ek dosyalar (sıralı). Yeni ekler önce /api/files ile yüklenir. */
|
||||||
attachmentIds: z
|
attachmentIds: z
|
||||||
.array(z.string().regex(/^[a-f0-9]{24}$/i))
|
.array(z.string().regex(/^[a-f0-9]{24}$/i))
|
||||||
@@ -67,6 +76,9 @@ export const transactionFormSchema = z
|
|||||||
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount });
|
ctx.addIssue({ code: "custom", path: ["toAccountId"], message: t.sameAccount });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (x.installments > 1 && (x.type !== "expense" || x.splits.length > 0)) {
|
||||||
|
ctx.addIssue({ code: "custom", path: ["installments"], message: t.installmentsExpenseOnly });
|
||||||
|
}
|
||||||
if (x.splits.length === 1) {
|
if (x.splits.length === 1) {
|
||||||
ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin });
|
ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin });
|
||||||
} else if (x.splits.length >= 2) {
|
} else if (x.splits.length >= 2) {
|
||||||
|
|||||||
Reference in New Issue
Block a user