Files
kovakemlak-crm/src/lib/appwrite/invoice-actions.ts
T
egecankomur 37679e83e6 init: kovakemlak-crm project scaffold
- Next.js 16 + Appwrite multi-tenant emlak CRM
- Database: kovakemlak-db (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings)
- Same stack as isletmem-kovakcrm (shadcn/ui template base)
- Modules: portföy, müşteri takibi, arama kriterleri, otomatik eşleştirme, sunum linki, yatırımcı portalı
2026-05-05 04:37:04 +03:00

621 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> {
const out: Record<string, string> = {};
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<void> {
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<InvoiceActionState> {
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<InvoiceActionState> {
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<InvoiceActionState> {
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<InvoiceActionState> {
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<InvoiceActionState> {
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<string | null> {
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<InvoiceActionState> {
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 };
}