b632ae8a73
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.
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
"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 };
|
||
}
|