feat: Faz 7b - kart ve KMH dönemleri, taksitler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:19:17 +03:00
36 changed files with 1770 additions and 51 deletions
+21 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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": {
+12
View File
@@ -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();
},
};
+2 -1
View File
@@ -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";
+68
View File
@@ -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;
}
+16
View File
@@ -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
View File
@@ -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 = [
+27 -19
View File
@@ -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 });
+11
View File
@@ -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")}
/> />
); );
+1 -1
View File
@@ -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) =>
+2
View File
@@ -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({
+1
View File
@@ -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,
}; };
} }
+14
View File
@@ -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,
+297
View File
@@ -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();
});
});
+396
View File
@@ -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,
+98 -16
View File
@@ -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(
+2 -2
View File
@@ -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,
+62
View File
@@ -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",
+5
View File
@@ -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 },
}, },
+38
View File
@@ -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);
+1
View File
@@ -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" } } },
+82
View File
@@ -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);
});
});
+113
View File
@@ -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 };
}
+2
View File
@@ -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) {
+12
View File
@@ -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) {