Files
isletmem-kovakcrm/src/lib/appwrite/loan-actions.ts
T
kovakmedya b632ae8a73 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.
2026-04-30 07:29:24 +03:00

430 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 };
}