feat(cards): kart/KMH dönem hesabı, taksitli harcama, kart ve KMH özeti servisleri, 006 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:19:17 +03:00
parent 57067b0fd0
commit cc624af69f
22 changed files with 1113 additions and 19 deletions
@@ -79,6 +79,7 @@ type FormValues = {
cardDueKind: AccountFormInput["cardDueKind"];
cardDueValue: string;
cardPaymentAccountId: string;
cardMinimumRate: string;
};
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
@@ -105,6 +106,7 @@ function toValues(a?: AccountView): FormValues {
cardDueKind: "daysAfter",
cardDueValue: "10",
cardPaymentAccountId: "",
cardMinimumRate: "",
};
}
const isCard = a.type === "credit_card";
@@ -133,6 +135,10 @@ function toValues(a?: AccountView): FormValues {
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
cardMinimumRate:
a.creditCard?.minimumPaymentRateBp != null
? formatMoneyInput(a.creditCard.minimumPaymentRateBp)
: "",
};
}
@@ -499,6 +505,28 @@ export function AccountFormDialog({
</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>
</FieldSet>
) : null}
@@ -35,6 +35,7 @@ const base = {
cardDueKind: "daysAfter" as const,
cardDueValue: 10,
cardPaymentAccountId: null,
cardMinimumRate: null,
};
const accountId = (name: string, householdId = cakir) =>
+2
View File
@@ -55,6 +55,7 @@ export type AccountView = {
statementDay: number;
dueRule: { kind: DueRuleKind; value: number };
paymentAccountId: string | null;
minimumPaymentRateBp: number | null;
} | null;
};
@@ -229,6 +230,7 @@ async function toDocFields(
statementDay: input.cardStatementDay,
dueRule: { kind: input.cardDueKind, value: input.cardDueValue },
paymentAccountId,
minimumPaymentRateBp: input.cardMinimumRate,
}
: null,
});
@@ -67,6 +67,7 @@ async function form(over: Partial<TransactionForm>): Promise<TransactionForm> {
description: "",
tags: [],
attachmentIds: [],
installments: 1,
...over,
};
}
@@ -204,6 +204,7 @@ describe("kural ve planlı kayıtlar", () => {
description: "",
tags: [],
attachmentIds: [],
installments: 1,
});
expect(u.ok).toBe(true);
expect(await TransactionModel.findById(d.id).lean()).toMatchObject({
+1
View File
@@ -86,6 +86,7 @@ function asTransactionForm(input: RecurringRuleForm): TransactionForm {
description: input.description,
tags: input.tags,
attachmentIds: [],
installments: 1,
};
}
+14
View File
@@ -8,6 +8,7 @@ import { getHouseholdActionContext } from "@/lib/dal";
import { reconcileSchema } from "@/shared/schemas/reconciliation";
import { transactionFormSchema } from "@/shared/schemas/transaction";
import {
cancelRemainingInstallments,
createTransaction,
getTransaction,
getTransactionFormOptions,
@@ -106,6 +107,19 @@ export async function searchPayeesAction(query: unknown): Promise<PayeeSuggestio
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. */
export async function previewReconciliationAction(
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,
};
}
@@ -52,6 +52,7 @@ function form(over: Partial<TransactionForm>): TransactionForm {
description: "",
tags: [],
attachmentIds: [],
installments: 1,
...over,
};
}
@@ -84,6 +85,7 @@ describe("bakiye tutarlılığı", () => {
cardDueKind: "daysAfter" as const,
cardDueValue: 10,
cardPaymentAccountId: null,
cardMinimumRate: null,
};
const b = await createAccount(ids.user("ayse"), cakir, {
...base,
+98 -16
View File
@@ -19,7 +19,8 @@ import { TransactionModel } from "@/models/transaction";
import type { AccountType } from "@/shared/accounts";
import { allowedAttachmentKinds } from "@/shared/attachments";
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 { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
import { compareTr, foldTr } from "@/shared/search";
@@ -66,6 +67,7 @@ export async function loadContext(
) {
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.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.
* 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(
householdId: Types.ObjectId,
): Promise<Map<string, number>> {
await postDueInstallments(householdId);
const pipeline: PipelineStage[] = [
{ $match: { householdId, status: "posted", deletedAt: null } },
{
@@ -318,6 +339,11 @@ export async function createTransaction(
const built = buildDoc(ctx, input);
if (!built.ok) return built;
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.
const payeeId = await touchPayee(
ctx.m.householdId,
@@ -330,21 +356,33 @@ export async function createTransaction(
try {
let id = "";
await mongoose.connection.transaction(async (session) => {
const [doc] = await TransactionModel.create(
[
{
...built.data,
householdId: ctx.m.householdId,
status: "posted",
payeeId,
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
id = String(doc!._id);
await attach(ctx, actorId, doc!._id, input, session);
const base = {
...built.data,
householdId: ctx.m.householdId,
status: "posted",
payeeId,
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();
const docs =
input.installments > 1
? installmentSchedule(input.date, input.amount, input.installments).map((x) => ({
...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 });
} catch (error) {
@@ -486,6 +524,41 @@ export async function setTransactionDeleted(
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 ---
export type TransactionListItem = {
@@ -507,6 +580,8 @@ export type TransactionListItem = {
isAdjustment: boolean;
/** Mutabakatı yapılmış hesaplar. */
reconciledAccountIds: string[];
/** Taksit: "4/9" */
installment: { planId: string; index: number; count: number } | null;
};
export type TransactionDetail = TransactionListItem & {
@@ -567,6 +642,13 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
attachmentCount: tx.attachmentCount ?? 0,
isAdjustment: Boolean(tx.isAdjustment),
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: "",
tags: [],
attachmentIds: [],
installments: 1,
} satisfies TransactionForm);
expect(r.ok).toBe(true);
const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!;
@@ -172,6 +173,7 @@ describe("mutabakat", () => {
description: "",
tags: [],
attachmentIds: [],
installments: 1,
});
expect(edit.ok).toBe(false);
expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true);
@@ -215,6 +217,7 @@ describe("mutabakat", () => {
description: "yeni açıklama",
tags: d.tags,
attachmentIds: [],
installments: 1,
};
expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true);
expect(
+2 -2
View File
@@ -36,7 +36,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const MAX_ROWS = 2000;
/** 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 {
householdId,
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. */
async function balanceBefore(
export async function balanceBefore(
householdId: Types.ObjectId,
account: { _id: Types.ObjectId; openingBalanceMinor: number },
before: Date | null,
+5
View File
@@ -26,6 +26,11 @@ const creditCardSchema = new Schema(
kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" },
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. */
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, effectiveCategoryIds: 1, date: -1 });
transactionSchema.index({ householdId: 1, tags: 1 });
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
transactionSchema.index(
{ recurringRuleId: 1, occurrenceIndex: 1 },
{ 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),
cardDueValue: day(31),
cardPaymentAccountId: objectIdOrEmpty,
/** Boş → varsayılan kural (limite göre %20 / %40). */
cardMinimumRate: z.string().default("").pipe(percentToBp),
})
.superRefine((a, ctx) => {
if (a.type === "meal_card" && !a.mealCardProvider) {
+12
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { tr } from "../../i18n/tr";
import { parseMoney } from "../money";
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
import { MAX_INSTALLMENTS } from "../card-periods";
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
const v = tr.validation;
@@ -53,6 +54,14 @@ export const transactionFormSchema = z
.union([z.string(), z.array(z.string())])
.default("")
.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. */
attachmentIds: z
.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 });
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) {
ctx.addIssue({ code: "custom", path: ["splits"], message: t.splitMin });
} else if (x.splits.length >= 2) {