feat(banking B): bank loans + amortization schedule

Step 2 of banking. Loan creation auto-generates the full installment
schedule using standard amortization (eşit taksitli kredi):
  monthlyPayment = P × r × (1+r)^n / ((1+r)^n − 1)

Schema:
- bank_loans: bankAccountId (optional FK), bankName, loanName, loanType
  enum (consumer/vehicle/housing/commercial/kmh/other), principal,
  interestRate (monthly nominal %), termMonths, monthlyPayment, startDate,
  paymentDay (1-28, clamped per month), status (active/closed/defaulted).
- loan_installments: loanId, installmentNo, dueDate, amount, principalPart,
  interestPart, paid, paidAt, financeEntryId.
- Indexes on bank_loans(tenantId, status) and loan_installments(tenantId,
  loanId) and (tenantId, paid, dueDate).

Server (lib/appwrite/loan-actions.ts):
- createLoanAction: validates with Zod, computes amortization including
  rounding-drift handling on the last installment, persists loan + N
  installments, audit-logs. Atomic rollback on failure (deletes any
  partially-created installments and the loan).
- payInstallmentAction: atomically creates a finance_entry (expense,
  bankAccountId carried over from the loan), updates installment with
  paid=true + financeEntryId. If it was the last unpaid installment,
  marks loan status='closed'.
- unpayInstallmentAction: deletes the linked finance_entry, clears paid
  fields, reopens the loan if it was closed.
- deleteLoanAction: cascade-deletes all installments first, then the loan.

UI (/finance/loans):
- 3 stat cards: aktif kredi sayısı, toplam çekilen, kalan ödeme.
- Loan card per loan with bank/name/type/status badges, anapara/aylık
  taksit/faiz/sonraki ödeme grid, progress bar (paid/total), expandable
  installment table.
- Installment row: # / vade (red if overdue) / anapara / faiz / toplam /
  Ödendi-Geri al toggle.
- LoanFormSheet: live preview of monthly payment, total payment, total
  interest as user changes principal/rate/term. paymentDay clamped 1-28
  to avoid month-length issues.
This commit is contained in:
kovakmedya
2026-04-30 07:29:24 +03:00
parent 7b6be623ae
commit b632ae8a73
9 changed files with 1347 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import {
DATABASE_ID,
TABLES,
type BankLoan,
type LoanInstallment,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { LoanActionState } from "./loan-types";
import { bankLoanSchema } from "@/lib/validation/bank-loans";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
return "Bağlantı hatası. Tekrar deneyin.";
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
function pickLoanFields(formData: FormData) {
return {
bankAccountId: String(formData.get("bankAccountId") ?? ""),
bankName: String(formData.get("bankName") ?? "").trim(),
loanName: String(formData.get("loanName") ?? "").trim(),
loanType: formData.get("loanType") as
| "consumer"
| "vehicle"
| "housing"
| "commercial"
| "kmh"
| "other"
| null,
principal: String(formData.get("principal") ?? "0"),
interestRate: String(formData.get("interestRate") ?? "0"),
termMonths: String(formData.get("termMonths") ?? "12"),
startDate: String(formData.get("startDate") ?? ""),
paymentDay: String(formData.get("paymentDay") ?? "1"),
notes: String(formData.get("notes") ?? "").trim(),
};
}
function toIso(v: string): string {
if (!v) return v;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
/**
* Standard amortization (eşit taksitli kredi).
* monthlyPayment = P × r × (1+r)^n / ((1+r)^n 1)
* where r = monthly interest rate (decimal), n = termMonths
*/
function computeAmortization(
principal: number,
monthlyRatePct: number,
n: number,
): {
monthlyPayment: number;
schedule: Array<{ principalPart: number; interestPart: number; amount: number }>;
} {
const r = monthlyRatePct / 100;
let monthlyPayment: number;
if (r === 0) {
monthlyPayment = principal / n;
} else {
const factor = Math.pow(1 + r, n);
monthlyPayment = (principal * r * factor) / (factor - 1);
}
monthlyPayment = Number(monthlyPayment.toFixed(2));
const schedule: Array<{ principalPart: number; interestPart: number; amount: number }> = [];
let remaining = principal;
for (let i = 0; i < n; i++) {
const interestPart = Number((remaining * r).toFixed(2));
let principalPart = Number((monthlyPayment - interestPart).toFixed(2));
// Final installment absorbs rounding drift
if (i === n - 1) {
principalPart = Number(remaining.toFixed(2));
}
const amount = Number((interestPart + principalPart).toFixed(2));
remaining = Number((remaining - principalPart).toFixed(2));
schedule.push({ principalPart, interestPart, amount });
}
return { monthlyPayment, schedule };
}
function shiftMonth(date: Date, monthsAhead: number, paymentDay: number): Date {
const d = new Date(date.getFullYear(), date.getMonth() + monthsAhead, 1);
// clamp paymentDay to last day of that month
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(paymentDay, lastDay));
return d;
}
export async function createLoanAction(
_prev: LoanActionState,
formData: FormData,
): Promise<LoanActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = bankLoanSchema.safeParse(pickLoanFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
const { schedule, monthlyPayment } = computeAmortization(
parsed.data.principal,
parsed.data.interestRate,
parsed.data.termMonths,
);
let loanId: string | null = null;
const admin = createAdminClient();
try {
const loan = await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.bankLoans,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
bankAccountId: parsed.data.bankAccountId,
bankName: parsed.data.bankName,
loanName: parsed.data.loanName,
loanType: parsed.data.loanType,
principal: parsed.data.principal,
interestRate: parsed.data.interestRate,
termMonths: parsed.data.termMonths,
monthlyPayment,
startDate: toIso(parsed.data.startDate),
paymentDay: parsed.data.paymentDay,
status: "active",
notes: parsed.data.notes,
},
teamRowPermissions(ctx.tenantId),
);
loanId = loan.$id;
const start = new Date(toIso(parsed.data.startDate));
for (let i = 0; i < parsed.data.termMonths; i++) {
const due = shiftMonth(start, i + 1, parsed.data.paymentDay ?? 1);
const slice = schedule[i];
await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.loanInstallments,
ID.unique(),
{
tenantId: ctx.tenantId,
loanId,
installmentNo: i + 1,
dueDate: due.toISOString(),
amount: slice.amount,
principalPart: slice.principalPart,
interestPart: slice.interestPart,
paid: false,
},
teamRowPermissions(ctx.tenantId),
);
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "bank_loan",
entityId: loan.$id,
changes: { ...parsed.data, monthlyPayment, installments: parsed.data.termMonths },
});
} catch (e) {
if (loanId) {
// Best-effort rollback: delete partially-created installments + loan
try {
const partial = await admin.tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", loanId),
Query.limit(500),
],
});
for (const r of partial.rows) {
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
}
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, loanId);
} catch {
/* ignore */
}
}
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
return { ok: true, loanId };
}
export async function deleteLoanAction(formData: FormData): Promise<LoanActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.bankLoans,
id,
)) as unknown as BankLoan;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Delete installments first
const installments = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", id),
Query.limit(500),
],
});
for (const r of installments.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "bank_loan",
entityId: id,
changes: { loanName: existing.loanName, installments: installments.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
return { ok: true };
}
export async function payInstallmentAction(formData: FormData): Promise<LoanActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.loanInstallments,
id,
)) as unknown as LoanInstallment;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
if (existing.paid) {
return { ok: false, error: "Bu taksit zaten ödenmiş." };
}
const loan = (await tablesDB.getRow(
DATABASE_ID,
TABLES.bankLoans,
existing.loanId,
)) as unknown as BankLoan;
// Create finance entry: expense, linked
const fe = await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
type: "expense",
amount: existing.amount,
date: new Date().toISOString(),
description: `${loan.bankName}${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
bankAccountId: loan.bankAccountId,
},
teamRowPermissions(ctx.tenantId),
);
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
paid: true,
paidAt: new Date().toISOString(),
financeEntryId: fe.$id,
});
// If this was the last unpaid one, mark loan closed
const remaining = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("loanId", existing.loanId),
Query.equal("paid", false),
Query.limit(1),
],
});
if (remaining.rows.length === 0) {
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
status: "closed",
});
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "loan_installment",
entityId: id,
changes: { paid: true, financeEntryId: fe.$id, amount: existing.amount },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
revalidatePath("/finance");
return { ok: true };
}
export async function unpayInstallmentAction(
formData: FormData,
): Promise<LoanActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.loanInstallments,
id,
)) as unknown as LoanInstallment;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
if (!existing.paid) return { ok: true };
if (existing.financeEntryId) {
try {
await tablesDB.deleteRow(
DATABASE_ID,
TABLES.financeEntries,
existing.financeEntryId,
);
} catch {
/* ignore: maybe already gone */
}
}
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
paid: false,
paidAt: null,
financeEntryId: null,
});
// If loan was closed, reopen it
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
status: "active",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "loan_installment",
entityId: id,
changes: { paid: false },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/loans");
revalidatePath("/finance");
return { ok: true };
}