"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 { const out: Record = {}; 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 { 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 { 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 { 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 { 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 }; }