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:
@@ -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>;
|
||||
Reference in New Issue
Block a user