From b632ae8a73ca759f51d72315200bba0b56570b8a Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 07:29:24 +0300 Subject: [PATCH] feat(banking B): bank loans + amortization schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../loans/components/loan-form-sheet.tsx | 254 +++++++++++ .../finance/loans/components/loans-client.tsx | 357 +++++++++++++++ .../finance/loans/components/types.ts | 49 ++ src/app/(dashboard)/finance/loans/page.tsx | 115 +++++ src/lib/appwrite/loan-actions.ts | 429 ++++++++++++++++++ src/lib/appwrite/loan-queries.ts | 65 +++ src/lib/appwrite/loan-types.ts | 8 + src/lib/appwrite/schema.ts | 35 ++ src/lib/validation/bank-loans.ts | 35 ++ 9 files changed, 1347 insertions(+) create mode 100644 src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx create mode 100644 src/app/(dashboard)/finance/loans/components/loans-client.tsx create mode 100644 src/app/(dashboard)/finance/loans/components/types.ts create mode 100644 src/app/(dashboard)/finance/loans/page.tsx create mode 100644 src/lib/appwrite/loan-actions.ts create mode 100644 src/lib/appwrite/loan-queries.ts create mode 100644 src/lib/appwrite/loan-types.ts create mode 100644 src/lib/validation/bank-loans.ts diff --git a/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx new file mode 100644 index 0000000..6d87335 --- /dev/null +++ b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useActionState, useEffect, useState } from "react"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Textarea } from "@/components/ui/textarea"; +import { createLoanAction } from "@/lib/appwrite/loan-actions"; +import { initialLoanState } from "@/lib/appwrite/loan-types"; +import { formatTRY } from "@/lib/format"; + +import type { BankAccountOption } from "./types"; + +const NONE = "__none__"; + +function computeMonthly(principal: number, ratePct: number, n: number): number { + if (!principal || !n) return 0; + const r = ratePct / 100; + if (r === 0) return Number((principal / n).toFixed(2)); + const factor = Math.pow(1 + r, n); + return Number(((principal * r * factor) / (factor - 1)).toFixed(2)); +} + +export function LoanFormSheet({ + open, + onOpenChange, + bankAccounts, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + bankAccounts: BankAccountOption[]; +}) { + const [state, formAction, isPending] = useActionState(createLoanAction, initialLoanState); + const [principal, setPrincipal] = useState(0); + const [rate, setRate] = useState(2.5); + const [term, setTerm] = useState(24); + + useEffect(() => { + if (state.ok) { + toast.success("Kredi kaydedildi, taksitler oluşturuldu."); + onOpenChange(false); + } else if (state.error) { + toast.error(state.error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + const monthly = computeMonthly(principal, rate, term); + const total = monthly * term; + const today = new Date().toISOString().slice(0, 10); + + return ( + + + + Yeni kredi + + Kaydedince {term || 0} adet taksit otomatik hesaplanır ve eklenir. + + + +
{ + if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", ""); + formAction(fd); + }} + className="flex flex-1 flex-col" + > +
+
+
+ + + {state.fieldErrors?.bankName && ( +

{state.fieldErrors.bankName}

+ )} +
+
+ + +
+
+ +
+ + +
+ +
+ + +

+ Taksit ödemeleri seçilen hesaba expense olarak yazılır. +

+
+ +
+
+ + setPrincipal(Number(e.target.value) || 0)} + /> + {state.fieldErrors?.principal && ( +

{state.fieldErrors.principal}

+ )} +
+
+ + setRate(Number(e.target.value) || 0)} + /> +
+
+ + setTerm(Number(e.target.value) || 0)} + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ Aylık taksit + {formatTRY(monthly)} +
+
+ Toplam ödeme + {formatTRY(total)} +
+
+ Toplam faiz + {formatTRY(Math.max(0, total - principal))} +
+
+ +
+ +