"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 Invoice, type InvoiceItem, type TenantSettings, } from "./schema"; import { createAdminClient } from "./server"; import { requireTenant } from "./tenant-guard"; import type { InvoiceActionState } from "./invoice-types"; import { invoiceItemSchema, invoiceSchema } from "@/lib/validation/invoices"; 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 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; } async function nextInvoiceNumber( tenantId: string, ): Promise<{ number: string; settingsId: string | null }> { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tenantSettings, queries: [Query.equal("tenantId", tenantId), Query.limit(1)], }); const settings = result.rows[0] as unknown as TenantSettings | undefined; const prefix = settings?.invoicePrefix || "INV"; const counter = (settings?.invoiceCounter ?? 0) + 1; const year = new Date().getFullYear(); const number = `${prefix}-${year}-${String(counter).padStart(4, "0")}`; if (settings) { await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, settings.$id, { invoiceCounter: counter, }); return { number, settingsId: settings.$id }; } return { number, settingsId: null }; } /** * Reflect invoice payment status into finance_entries. * - status === "paid": ensure exactly one income entry exists for this invoice. * - otherwise: remove any income entries linked to this invoice (auto-generated). * * Best-effort. Failures here must not break the invoice mutation; we log the * error to audit but do not throw. */ async function syncPaymentEntry( tenantId: string, userId: string, invoice: Invoice, ): Promise { try { const { tablesDB } = createAdminClient(); const linked = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.financeEntries, queries: [ Query.equal("tenantId", tenantId), Query.equal("invoiceId", invoice.$id), Query.equal("type", "income"), Query.limit(10), ], }); if (invoice.status === "paid") { if (linked.rows.length === 0) { const row = await tablesDB.createRow( DATABASE_ID, TABLES.financeEntries, ID.unique(), { tenantId, createdBy: userId, type: "income", amount: Number((invoice.total ?? 0).toFixed(2)), date: new Date().toISOString(), description: `Fatura ${invoice.number} tahsilatı`, customerId: invoice.customerId, invoiceId: invoice.$id, scope: "company", }, [ Permission.read(Role.team(tenantId)), Permission.update(Role.team(tenantId)), Permission.delete(Role.team(tenantId, "owner")), Permission.delete(Role.team(tenantId, "admin")), ], ); await logAudit({ tenantId, userId, action: "create", entityType: "finance_entry", entityId: row.$id, changes: { auto: "invoice_paid", invoiceId: invoice.$id, amount: invoice.total }, }); } else { // Keep the first; resync amount in case the invoice total changed const first = linked.rows[0]; const desiredAmount = Number((invoice.total ?? 0).toFixed(2)); if ( (first as unknown as { amount: number }).amount !== desiredAmount ) { await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, first.$id, { amount: desiredAmount, }); } } } else { for (const r of linked.rows) { await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id); await logAudit({ tenantId, userId, action: "delete", entityType: "finance_entry", entityId: r.$id, changes: { auto: "invoice_unpaid", invoiceId: invoice.$id }, }); } } } catch { // best-effort; don't block the invoice update } } async function recomputeTotals( tenantId: string, invoiceId: string, userId?: string, ): Promise<{ subtotal: number; vatTotal: number; total: number }> { const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.invoiceItems, queries: [ Query.equal("tenantId", tenantId), Query.equal("invoiceId", invoiceId), Query.limit(500), ], }); let subtotal = 0; let vatTotal = 0; for (const r of result.rows as unknown as InvoiceItem[]) { const lineNet = (r.quantity ?? 0) * (r.unitPrice ?? 0); const lineVat = lineNet * ((r.vatRate ?? 0) / 100); subtotal += lineNet; vatTotal += lineVat; } const total = subtotal + vatTotal; await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, invoiceId, { subtotal: Number(subtotal.toFixed(2)), vatTotal: Number(vatTotal.toFixed(2)), total: Number(total.toFixed(2)), }); // If invoice is paid, keep the linked income entry's amount in sync. if (userId) { try { const refreshed = (await tablesDB.getRow( DATABASE_ID, TABLES.invoices, invoiceId, )) as unknown as Invoice; if (refreshed.status === "paid") { await syncPaymentEntry(tenantId, userId, refreshed); } } catch { /* best-effort */ } } return { subtotal, vatTotal, total }; } // -------------------- Invoice header -------------------- function pickInvoiceFields(formData: FormData) { return { customerId: String(formData.get("customerId") ?? ""), issueDate: String(formData.get("issueDate") ?? ""), dueDate: String(formData.get("dueDate") ?? ""), status: (formData.get("status") as "draft" | "sent" | "paid" | "overdue" | "cancelled" | null) ?? "draft", notes: String(formData.get("notes") ?? "").trim(), }; } export async function createInvoiceAction( _prev: InvoiceActionState, formData: FormData, ): Promise { let ctx; try { ctx = await requireTenant(); } catch { return { ok: false, error: "Yetkiniz yok." }; } const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData)); if (!parsed.success) { return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; } let invoiceId = ""; try { const { tablesDB } = createAdminClient(); const { number } = await nextInvoiceNumber(ctx.tenantId); const data = { ...parsed.data, issueDate: toIso(parsed.data.issueDate), dueDate: toIso(parsed.data.dueDate), number, subtotal: 0, vatTotal: 0, total: 0, }; const row = await tablesDB.createRow( DATABASE_ID, TABLES.invoices, ID.unique(), { tenantId: ctx.tenantId, createdBy: ctx.user.id, ...data, }, teamRowPermissions(ctx.tenantId), ); invoiceId = row.$id; await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", entityType: "invoice", entityId: row.$id, changes: { number, customerId: parsed.data.customerId }, }); } catch (e) { return { ok: false, error: appwriteError(e) }; } revalidatePath("/invoices"); return { ok: true, invoiceId }; } export async function updateInvoiceAction( _prev: InvoiceActionState, 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." }; } const parsed = invoiceSchema.safeParse(pickInvoiceFields(formData)); if (!parsed.success) { return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; } try { const { tablesDB } = createAdminClient(); const existing = (await tablesDB.getRow( DATABASE_ID, TABLES.invoices, id, )) as unknown as Invoice; if (existing.tenantId !== ctx.tenantId) { return { ok: false, error: "Erişim engellendi." }; } const data = { ...parsed.data, issueDate: toIso(parsed.data.issueDate), dueDate: toIso(parsed.data.dueDate), }; await tablesDB.updateRow(DATABASE_ID, TABLES.invoices, id, data); await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "invoice", entityId: id, changes: data, }); // Sync payment ↔ finance entry on every save (cheap; idempotent). const refreshed = (await tablesDB.getRow( DATABASE_ID, TABLES.invoices, id, )) as unknown as Invoice; await syncPaymentEntry(ctx.tenantId, ctx.user.id, refreshed); } catch (e) { return { ok: false, error: appwriteError(e) }; } revalidatePath("/invoices"); revalidatePath(`/invoices/${id}`); revalidatePath("/finance"); return { ok: true, invoiceId: id }; } export async function deleteInvoiceAction( 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.invoices, id, )) as unknown as Invoice; if (existing.tenantId !== ctx.tenantId) { return { ok: false, error: "Erişim engellendi." }; } // Delete items first const items = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.invoiceItems, queries: [ Query.equal("tenantId", ctx.tenantId), Query.equal("invoiceId", id), Query.limit(500), ], }); for (const it of items.rows) { await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id); } // Cascade-delete any auto-generated income entries linked to this invoice const linkedEntries = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.financeEntries, queries: [ Query.equal("tenantId", ctx.tenantId), Query.equal("invoiceId", id), Query.limit(50), ], }); for (const r of linkedEntries.rows) { await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id); } await tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id); await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", entityType: "invoice", entityId: id, changes: { number: existing.number, items: items.rows.length, linkedFinanceEntries: linkedEntries.rows.length, }, }); } catch (e) { return { ok: false, error: appwriteError(e) }; } revalidatePath("/invoices"); revalidatePath("/finance"); return { ok: true }; } // -------------------- Invoice items -------------------- function pickItemFields(formData: FormData) { return { description: String(formData.get("description") ?? "").trim(), quantity: String(formData.get("quantity") ?? "1"), unitPrice: String(formData.get("unitPrice") ?? "0"), vatRate: String(formData.get("vatRate") ?? "20"), }; } function lineTotal(qty: number, unitPrice: number, vatRate: number): number { const net = qty * unitPrice; const vat = net * (vatRate / 100); return Number((net + vat).toFixed(2)); } export async function addInvoiceItemAction( _prev: InvoiceActionState, formData: FormData, ): Promise { const invoiceId = String(formData.get("invoiceId") ?? ""); if (!invoiceId) return { ok: false, error: "Fatura ID eksik." }; let ctx; try { ctx = await requireTenant(); } catch { return { ok: false, error: "Yetkiniz yok." }; } const parsed = invoiceItemSchema.safeParse(pickItemFields(formData)); if (!parsed.success) { return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) }; } try { const { tablesDB } = createAdminClient(); const invoice = (await tablesDB.getRow( DATABASE_ID, TABLES.invoices, invoiceId, )) as unknown as Invoice; if (invoice.tenantId !== ctx.tenantId) { return { ok: false, error: "Erişim engellendi." }; } const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0); const row = await tablesDB.createRow( DATABASE_ID, TABLES.invoiceItems, ID.unique(), { tenantId: ctx.tenantId, createdBy: ctx.user.id, invoiceId, description: parsed.data.description, quantity: parsed.data.quantity, unitPrice: parsed.data.unitPrice, vatRate: parsed.data.vatRate ?? 0, lineTotal: total, }, teamRowPermissions(ctx.tenantId), ); await recomputeTotals(ctx.tenantId, invoiceId, ctx.user.id); await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "create", entityType: "invoice_item", entityId: row.$id, changes: { invoiceId, ...parsed.data, lineTotal: total }, }); } catch (e) { return { ok: false, error: appwriteError(e) }; } revalidatePath(`/invoices/${invoiceId}`); return { ok: true, invoiceId }; } export async function updateInvoiceItemAction( _prev: InvoiceActionState, 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." }; } const parsed = invoiceItemSchema.safeParse(pickItemFields(formData)); if (!parsed.success) { return { ok: false, error: "Kalem geçersiz.", fieldErrors: flattenErrors(parsed.error) }; } try { const { tablesDB } = createAdminClient(); const existing = (await tablesDB.getRow( DATABASE_ID, TABLES.invoiceItems, id, )) as unknown as InvoiceItem; if (existing.tenantId !== ctx.tenantId) { return { ok: false, error: "Erişim engellendi." }; } const total = lineTotal(parsed.data.quantity, parsed.data.unitPrice, parsed.data.vatRate ?? 0); await tablesDB.updateRow(DATABASE_ID, TABLES.invoiceItems, id, { description: parsed.data.description, quantity: parsed.data.quantity, unitPrice: parsed.data.unitPrice, vatRate: parsed.data.vatRate ?? 0, lineTotal: total, }); await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id); await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "update", entityType: "invoice_item", entityId: id, changes: { ...parsed.data, lineTotal: total }, }); } catch (e) { return { ok: false, error: appwriteError(e) }; } revalidatePath(`/invoices/${(await getItemInvoiceId(id)) ?? ""}`); return { ok: true }; } async function getItemInvoiceId(itemId: string): Promise { try { const { tablesDB } = createAdminClient(); const row = (await tablesDB.getRow( DATABASE_ID, TABLES.invoiceItems, itemId, )) as unknown as InvoiceItem; return row.invoiceId; } catch { return null; } } export async function deleteInvoiceItemAction( 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.invoiceItems, id, )) as unknown as InvoiceItem; if (existing.tenantId !== ctx.tenantId) { return { ok: false, error: "Erişim engellendi." }; } await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id); await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id); await logAudit({ tenantId: ctx.tenantId, userId: ctx.user.id, action: "delete", entityType: "invoice_item", entityId: id, changes: { invoiceId: existing.invoiceId }, }); revalidatePath(`/invoices/${existing.invoiceId}`); } catch (e) { return { ok: false, error: appwriteError(e) }; } return { ok: true }; }