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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user