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:
kovakmedya
2026-04-30 07:29:24 +03:00
parent 7b6be623ae
commit b632ae8a73
9 changed files with 1347 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
export const bankLoanSchema = z.object({
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
bankName: z.string().trim().min(1, "Banka adı zorunlu.").max(100),
loanName: z.string().trim().min(1, "Kredi adı zorunlu.").max(150),
loanType: z
.enum(["consumer", "vehicle", "housing", "commercial", "kmh", "other"])
.optional()
.default("consumer"),
principal: z
.union([z.number(), z.string()])
.transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
.pipe(z.number().positive("Anapara 0'dan büyük olmalı.")),
interestRate: z
.union([z.number(), z.string()])
.transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
.pipe(z.number().min(0, "Negatif olamaz.").max(100, "100'den büyük olamaz.")),
termMonths: z
.union([z.number(), z.string()])
.transform((v) => (typeof v === "string" ? parseInt(v, 10) : v))
.pipe(z.number().int().positive("Vade pozitif olmalı.").max(480, "Çok uzun.")),
startDate: z.string().min(1, "Başlangıç tarihi zorunlu."),
paymentDay: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 1;
const n = typeof v === "string" ? parseInt(v, 10) : v;
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1;
}),
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
});
export type BankLoanInput = z.infer<typeof bankLoanSchema>;