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
+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,
};
}