feat(banking C): credit cards + monthly statements

Final banking step. User-managed: each month, paste your bank statement
totals into the form. We don't auto-compute interest — bank's number is
authoritative.

Schema:
- credit_cards: bankName, cardName, last4, creditLimit, statementDay,
  dueDay, interestRate (monthly nominal %), bankAccountId (optional FK,
  payments expense from this account), archived, notes.
- credit_card_statements: cardId, period (YYYY-MM), statementDate,
  dueDate, totalDebt, minimumPayment, paidAmount, status
  (pending/partial/paid/overdue), financeEntryId (link to last payment).
- Indexes on (tenantId, archived) for cards and (tenantId, cardId) +
  (tenantId, status, dueDate) for statements.

Server (lib/appwrite/credit-card-actions.ts):
- create/update/archive(toggle)/delete for cards. Card delete cascades
  through statements + their finance_entries.
- createStatementAction: computes status from dueDate + paidAmount.
- payStatementAction: partial-payment friendly. Creates a single
  finance_entry expense for the pay amount, capped at remaining balance,
  bankAccountId carried from the card. Recomputes status: paid (full),
  partial (some + pre-due), overdue (past due with anything < total).
- deleteStatementAction: removes linked finance_entry too.
- All audit-logged.

UI (/finance/cards):
- 3 stat cards: aktif kart sayısı, bekleyen toplam, vadesi geçmiş ekstre.
- Per-card panel: bank/name/last4, limit + statement/due day + monthly
  interest, current outstanding. Statement table inside card with status
  badges, Öde button (opens partial-payment dialog), delete button.
- CardFormSheet for card CRUD, StatementFormSheet for statement creation
  with default period/dates derived from card's statementDay/dueDay.

Sidebar Finans submenu now functional: Banka hesapları → /finance/banks,
Krediler → /finance/loans, Kredi kartları → /finance/cards.
This commit is contained in:
kovakmedya
2026-04-30 07:36:31 +03:00
parent b632ae8a73
commit 121fbdba9d
10 changed files with 1719 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import { z } from "zod";
export const creditCardSchema = z.object({
bankName: z.string().trim().min(1, "Banka adı zorunlu.").max(100),
cardName: z.string().trim().min(1, "Kart adı zorunlu.").max(100),
last4: z
.string()
.trim()
.max(4)
.optional()
.transform((v) => (v ? v.replace(/\D/g, "").slice(-4) : undefined)),
creditLimit: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 0;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 0;
}),
statementDay: 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;
}),
dueDay: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 10;
const n = typeof v === "string" ? parseInt(v, 10) : v;
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 10;
}),
interestRate: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 4.25;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 4.25;
}),
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
});
export type CreditCardInput = z.infer<typeof creditCardSchema>;
export const statementSchema = z.object({
cardId: z.string().min(1, "Kart seçin."),
period: z
.string()
.regex(/^\d{4}-\d{2}$/, "Dönem YYYY-AA formatında olmalı.")
.max(7),
statementDate: z.string().min(1, "Hesap kesim tarihi zorunlu."),
dueDate: z.string().min(1, "Son ödeme tarihi zorunlu."),
totalDebt: z
.union([z.number(), z.string()])
.transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
.pipe(z.number().nonnegative("Negatif olamaz.")),
minimumPayment: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 0;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 0;
}),
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
});
export type StatementInput = z.infer<typeof statementSchema>;