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