feat: emlak CRM iskelet kurulumu
- schema.ts tamamen yeniden yazıldı (properties, customers, customer_searches, property_matches, presentations, investors, activities, tenant_settings) - Sidebar emlak modüllerine güncellendi (İlanlar, Müşteriler, Yatırımcılar, Sunumlar, Aktiviteler) - Eski CRM lib dosyaları temizlendi (finance, invoice, lead, task, software, vs.) - Yeni modül dizinleri oluşturuldu (stub pages) - command-search emlak navigasyonuna güncellendi - site-header temizlendi - Typecheck: 0 hata (chart.tsx template hariç)
This commit is contained in:
@@ -4,7 +4,9 @@ import { headers } from "next/headers";
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type AuditAction } from "./schema";
|
||||
import { DATABASE_ID, TABLES } from "./schema";
|
||||
|
||||
type AuditAction = string;
|
||||
|
||||
export async function logAudit(args: {
|
||||
tenantId: string;
|
||||
@@ -23,7 +25,7 @@ export async function logAudit(args: {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.auditLogs,
|
||||
"audit_logs",
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: args.tenantId,
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Query } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type BankAccount } from "./schema";
|
||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { BankAccountActionState } from "./bank-account-types";
|
||||
import { bankAccountSchema } from "@/lib/validation/bank-accounts";
|
||||
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
bankName: String(formData.get("bankName") ?? "").trim(),
|
||||
accountName: String(formData.get("accountName") ?? "").trim(),
|
||||
iban: String(formData.get("iban") ?? "").trim(),
|
||||
openingBalance: String(formData.get("openingBalance") ?? "0"),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBankAccountAction(
|
||||
_prev: BankAccountActionState,
|
||||
formData: FormData,
|
||||
): Promise<BankAccountActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = bankAccountSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.bankAccounts,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "bank_account",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/banks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateBankAccountAction(
|
||||
_prev: BankAccountActionState,
|
||||
formData: FormData,
|
||||
): Promise<BankAccountActionState> {
|
||||
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 = bankAccountSchema.safeParse(pickFormFields(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.bankAccounts,
|
||||
id,
|
||||
)) as unknown as BankAccount;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.bankAccounts,
|
||||
id,
|
||||
parsed.data,
|
||||
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/banks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveBankAccountAction(
|
||||
formData: FormData,
|
||||
): Promise<BankAccountActionState> {
|
||||
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.bankAccounts,
|
||||
id,
|
||||
)) as unknown as BankAccount;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const newArchivedState = !existing.archived;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.bankAccounts, id, {
|
||||
archived: newArchivedState,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
changes: { archived: newArchivedState },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/banks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteBankAccountAction(
|
||||
formData: FormData,
|
||||
): Promise<BankAccountActionState> {
|
||||
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.bankAccounts,
|
||||
id,
|
||||
)) as unknown as BankAccount;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Block delete if any finance_entry still references this account.
|
||||
const linked = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("bankAccountId", id),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (linked.rows.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Bu hesaba bağlı finans hareketleri var. Önce hesabı arşivleyin veya hareketleri başka bir hesaba taşıyın.",
|
||||
};
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.bankAccounts, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "bank_account",
|
||||
entityId: id,
|
||||
changes: { bankName: existing.bankName, accountName: existing.accountName },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/banks");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { canAccessRow } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type BankAccount,
|
||||
type FinanceEntry,
|
||||
} from "./schema";
|
||||
|
||||
/**
|
||||
* Returns bank accounts the current user is allowed to see:
|
||||
* - all `company` scope rows
|
||||
* - personal-scope rows where createdBy === currentUserId
|
||||
*/
|
||||
export async function listBankAccounts(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<BankAccount[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.bankAccounts,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("bankName"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as BankAccount[];
|
||||
if (!currentUserId) return rows;
|
||||
return rows.filter((r) => canAccessRow(r, currentUserId));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a current balance for each visible account.
|
||||
*/
|
||||
export async function getBankAccountBalances(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const balances = new Map<string, number>();
|
||||
try {
|
||||
const accounts = await listBankAccounts(tenantId, currentUserId);
|
||||
const visibleIds = new Set(accounts.map((a) => a.$id));
|
||||
for (const a of accounts) balances.set(a.$id, a.openingBalance ?? 0);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const entries = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.isNotNull("bankAccountId"),
|
||||
Query.limit(5000),
|
||||
],
|
||||
});
|
||||
for (const e of entries.rows as unknown as FinanceEntry[]) {
|
||||
if (!e.bankAccountId || !visibleIds.has(e.bankAccountId)) continue;
|
||||
const cur = balances.get(e.bankAccountId);
|
||||
if (cur === undefined) continue;
|
||||
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
|
||||
else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return balances;
|
||||
}
|
||||
|
||||
export async function listEntriesForAccount(
|
||||
tenantId: string,
|
||||
bankAccountId: string,
|
||||
limit = 25,
|
||||
): Promise<FinanceEntry[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("bankAccountId", bankAccountId),
|
||||
Query.orderDesc("date"),
|
||||
Query.limit(limit),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as FinanceEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type BankAccountActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialBankAccountState: BankAccountActionState = { ok: false };
|
||||
@@ -1,209 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type CalendarEvent } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { CalendarActionState } from "./calendar-types";
|
||||
import { calendarEventSchema } from "@/lib/validation/calendar";
|
||||
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
title: String(formData.get("title") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
start: String(formData.get("start") ?? ""),
|
||||
end: String(formData.get("end") ?? ""),
|
||||
allDay: formData.get("allDay") ?? false,
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
color: String(formData.get("color") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(v: string, allDay: boolean | undefined): string {
|
||||
if (!v) return v;
|
||||
// datetime-local => "YYYY-MM-DDTHH:mm"; date => "YYYY-MM-DD"
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||
return `${v}T00:00:00.000+00:00`;
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
// fallback for already-iso
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function createCalendarEventAction(
|
||||
_prev: CalendarActionState,
|
||||
formData: FormData,
|
||||
): Promise<CalendarActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = calendarEventSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = {
|
||||
...parsed.data,
|
||||
start: toIso(parsed.data.start, parsed.data.allDay),
|
||||
end: toIso(parsed.data.end, parsed.data.allDay),
|
||||
};
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.calendarEvents,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "calendar_event",
|
||||
entityId: row.$id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/calendar");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCalendarEventAction(
|
||||
_prev: CalendarActionState,
|
||||
formData: FormData,
|
||||
): Promise<CalendarActionState> {
|
||||
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 = calendarEventSchema.safeParse(pickFormFields(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.calendarEvents,
|
||||
id,
|
||||
)) as unknown as CalendarEvent;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = {
|
||||
...parsed.data,
|
||||
start: toIso(parsed.data.start, parsed.data.allDay),
|
||||
end: toIso(parsed.data.end, parsed.data.allDay),
|
||||
};
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.calendarEvents, id, data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "calendar_event",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/calendar");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCalendarEventAction(
|
||||
formData: FormData,
|
||||
): Promise<CalendarActionState> {
|
||||
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.calendarEvents,
|
||||
id,
|
||||
)) as unknown as CalendarEvent;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.calendarEvents, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "calendar_event",
|
||||
entityId: id,
|
||||
changes: { title: existing.title },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/calendar");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type CalendarEvent } from "./schema";
|
||||
|
||||
export async function listCalendarEvents(
|
||||
tenantId: string,
|
||||
rangeStart?: string,
|
||||
rangeEnd?: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const queries = [Query.equal("tenantId", tenantId), Query.limit(1000)];
|
||||
if (rangeStart) queries.push(Query.greaterThanEqual("start", rangeStart));
|
||||
if (rangeEnd) queries.push(Query.lessThanEqual("start", rangeEnd));
|
||||
queries.push(Query.orderAsc("start"));
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.calendarEvents,
|
||||
queries,
|
||||
});
|
||||
return result.rows as unknown as CalendarEvent[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type CalendarActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialCalendarState: CalendarActionState = { ok: false };
|
||||
@@ -1,504 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Query } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CreditCard,
|
||||
type CreditCardStatement,
|
||||
} from "./schema";
|
||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { CreditCardActionState } from "./credit-card-types";
|
||||
import { creditCardSchema, statementSchema } from "@/lib/validation/credit-cards";
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
// ---------------- Cards ----------------
|
||||
|
||||
function pickCardFields(formData: FormData) {
|
||||
return {
|
||||
bankName: String(formData.get("bankName") ?? "").trim(),
|
||||
cardName: String(formData.get("cardName") ?? "").trim(),
|
||||
last4: String(formData.get("last4") ?? "").trim(),
|
||||
creditLimit: String(formData.get("creditLimit") ?? "0"),
|
||||
statementDay: String(formData.get("statementDay") ?? "1"),
|
||||
dueDay: String(formData.get("dueDay") ?? "10"),
|
||||
interestRate: String(formData.get("interestRate") ?? "4.25"),
|
||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCreditCardAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = creditCardSchema.safeParse(pickCardFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "credit_card",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCreditCardAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
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 = creditCardSchema.safeParse(pickCardFields(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.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
id,
|
||||
parsed.data,
|
||||
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveCreditCardAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
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.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
const newArchivedState = !existing.archived;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, {
|
||||
archived: newArchivedState,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: { archived: newArchivedState },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCreditCardAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
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.creditCards,
|
||||
id,
|
||||
)) as unknown as CreditCard;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Cascade delete statements + their finance entries
|
||||
const statements = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("cardId", id),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const s of statements.rows as unknown as CreditCardStatement[]) {
|
||||
if (s.financeEntryId) {
|
||||
try {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, s.financeEntryId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, s.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCards, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "credit_card",
|
||||
entityId: id,
|
||||
changes: { bankName: existing.bankName, cardName: existing.cardName, statements: statements.rows.length },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------- Statements ----------------
|
||||
|
||||
function pickStatementFields(formData: FormData) {
|
||||
return {
|
||||
cardId: String(formData.get("cardId") ?? ""),
|
||||
period: String(formData.get("period") ?? "").trim(),
|
||||
statementDate: String(formData.get("statementDate") ?? ""),
|
||||
dueDate: String(formData.get("dueDate") ?? ""),
|
||||
totalDebt: String(formData.get("totalDebt") ?? "0"),
|
||||
minimumPayment: String(formData.get("minimumPayment") ?? "0"),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function computeStatus(
|
||||
totalDebt: number,
|
||||
paidAmount: number,
|
||||
dueDate: string,
|
||||
): "pending" | "partial" | "paid" | "overdue" {
|
||||
if (paidAmount >= totalDebt) return "paid";
|
||||
const past = new Date(dueDate).getTime() < Date.now();
|
||||
if (paidAmount > 0) return past ? "overdue" : "partial";
|
||||
return past ? "overdue" : "pending";
|
||||
}
|
||||
|
||||
export async function createStatementAction(
|
||||
_prev: CreditCardActionState,
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = statementSchema.safeParse(pickStatementFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const card = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
parsed.data.cardId,
|
||||
)) as unknown as CreditCard;
|
||||
if (card.tenantId !== ctx.tenantId || !canAccessRow(card, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const status = computeStatus(parsed.data.totalDebt, 0, parsed.data.dueDate);
|
||||
// Statements inherit the card's scope.
|
||||
const cardScope = card.scope ?? "company";
|
||||
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCardStatements,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
cardId: parsed.data.cardId,
|
||||
period: parsed.data.period,
|
||||
statementDate: toIso(parsed.data.statementDate),
|
||||
dueDate: toIso(parsed.data.dueDate),
|
||||
totalDebt: parsed.data.totalDebt,
|
||||
minimumPayment: parsed.data.minimumPayment,
|
||||
paidAmount: 0,
|
||||
status,
|
||||
notes: parsed.data.notes,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: row.$id,
|
||||
changes: { cardId: parsed.data.cardId, period: parsed.data.period, totalDebt: parsed.data.totalDebt },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function payStatementAction(formData: FormData): Promise<CreditCardActionState> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const amountStr = String(formData.get("amount") ?? "");
|
||||
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.creditCardStatements,
|
||||
id,
|
||||
)) as unknown as CreditCardStatement;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const remaining = (existing.totalDebt ?? 0) - (existing.paidAmount ?? 0);
|
||||
if (remaining <= 0) {
|
||||
return { ok: false, error: "Bu ekstrenin bakiyesi kalmamış." };
|
||||
}
|
||||
|
||||
const amount = amountStr
|
||||
? Number(amountStr.replace(",", "."))
|
||||
: remaining;
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return { ok: false, error: "Tutar geçersiz.", fieldErrors: { amount: "Geçersiz" } };
|
||||
}
|
||||
const payAmount = Math.min(amount, remaining);
|
||||
|
||||
const card = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
existing.cardId,
|
||||
)) as unknown as CreditCard;
|
||||
if (!canAccessRow(card, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
const cardScope = card.scope ?? "company";
|
||||
|
||||
const fe = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
type: "expense",
|
||||
amount: payAmount,
|
||||
date: new Date().toISOString(),
|
||||
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
|
||||
bankAccountId: card.bankAccountId,
|
||||
scope: cardScope,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
|
||||
);
|
||||
|
||||
const newPaid = (existing.paidAmount ?? 0) + payAmount;
|
||||
const newStatus = computeStatus(existing.totalDebt ?? 0, newPaid, existing.dueDate);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCardStatements, id, {
|
||||
paidAmount: Number(newPaid.toFixed(2)),
|
||||
status: newStatus,
|
||||
financeEntryId: newStatus === "paid" ? fe.$id : existing.financeEntryId ?? fe.$id,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: id,
|
||||
changes: { paidAmount: newPaid, status: newStatus, payAmount },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteStatementAction(
|
||||
formData: FormData,
|
||||
): Promise<CreditCardActionState> {
|
||||
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.creditCardStatements,
|
||||
id,
|
||||
)) as unknown as CreditCardStatement;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
// Statement inherits its parent card's scope.
|
||||
const parent = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.creditCards,
|
||||
existing.cardId,
|
||||
)) as unknown as CreditCard;
|
||||
if (!canAccessRow(parent, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
if (existing.financeEntryId) {
|
||||
try {
|
||||
await tablesDB.deleteRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
existing.financeEntryId,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "credit_card_statement",
|
||||
entityId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/cards");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { canAccessRow } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CreditCard,
|
||||
type CreditCardStatement,
|
||||
} from "./schema";
|
||||
|
||||
export async function listCreditCards(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<CreditCard[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCards,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("bankName"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as CreditCard[];
|
||||
if (!currentUserId) return rows;
|
||||
return rows.filter((r) => canAccessRow(r, currentUserId));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists statements whose parent card is visible to the user.
|
||||
*/
|
||||
export async function listStatements(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<CreditCardStatement[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const [allStmt, allCards] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("statementDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCards,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
|
||||
}),
|
||||
]);
|
||||
if (!currentUserId) return allStmt.rows as unknown as CreditCardStatement[];
|
||||
const visibleCardIds = new Set(
|
||||
(allCards.rows as unknown as CreditCard[])
|
||||
.filter((c) => canAccessRow(c, currentUserId))
|
||||
.map((c) => c.$id),
|
||||
);
|
||||
return (allStmt.rows as unknown as CreditCardStatement[]).filter((s) =>
|
||||
visibleCardIds.has(s.cardId),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type CreditCardActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialCreditCardState: CreditCardActionState = { ok: false };
|
||||
@@ -1,203 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
isPlanLimitError,
|
||||
planLimitMessage,
|
||||
requirePlanCapacity,
|
||||
} from "./plan-limits";
|
||||
import { DATABASE_ID, TABLES, type Customer } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { CustomerActionState } from "./customer-types";
|
||||
import { customerSchema } from "@/lib/validation/customers";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) {
|
||||
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
}
|
||||
return "Bağlantı hatası. Tekrar deneyin.";
|
||||
}
|
||||
|
||||
function pickFormFields(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
phone: String(formData.get("phone") ?? "").trim(),
|
||||
taxId: String(formData.get("taxId") ?? "").trim(),
|
||||
address: String(formData.get("address") ?? "").trim(),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
status: (formData.get("status") as "active" | "passive" | null) ?? "active",
|
||||
};
|
||||
}
|
||||
|
||||
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")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function createCustomerAction(
|
||||
_prev: CustomerActionState,
|
||||
formData: FormData,
|
||||
): Promise<CustomerActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = customerSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePlanCapacity(ctx, "customers");
|
||||
} catch (e) {
|
||||
if (isPlanLimitError(e)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: planLimitMessage(e.resource, e.limit),
|
||||
code: "PLAN_LIMIT_EXCEEDED",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "customer",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerAction(
|
||||
_prev: CustomerActionState,
|
||||
formData: FormData,
|
||||
): Promise<CustomerActionState> {
|
||||
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 = customerSchema.safeParse(pickFormFields(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.customers,
|
||||
id,
|
||||
)) as unknown as Customer;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.customers, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerAction(formData: FormData): Promise<CustomerActionState> {
|
||||
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.customers,
|
||||
id,
|
||||
)) as unknown as Customer;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.customers, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "customer",
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/customers");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type Customer } from "./schema";
|
||||
|
||||
export async function listCustomers(tenantId: string): Promise<Customer[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customers,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Customer[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomer(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
): Promise<Customer | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
id,
|
||||
)) as unknown as Customer;
|
||||
if (row.tenantId !== tenantId) return null;
|
||||
return row;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type CustomerActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
code?: "PLAN_LIMIT_EXCEEDED";
|
||||
};
|
||||
|
||||
export const initialCustomerState: CustomerActionState = { ok: false };
|
||||
@@ -1,236 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Customer,
|
||||
type FinanceEntry,
|
||||
type Invoice,
|
||||
type Task,
|
||||
} from "./schema";
|
||||
|
||||
export type DashboardData = {
|
||||
metrics: {
|
||||
totalCustomers: number;
|
||||
activeCustomers: number;
|
||||
monthIncome: number; // current month income
|
||||
prevMonthIncome: number; // previous month income (for delta)
|
||||
outstanding: number; // unpaid invoices total (draft+sent+overdue)
|
||||
overdueCount: number;
|
||||
openTasks: number;
|
||||
urgentTasks: number;
|
||||
};
|
||||
monthlyIncome: { month: string; income: number; expense: number }[]; // last 12 months
|
||||
topCustomers: { name: string; total: number }[]; // top 5 by paid invoice total
|
||||
recentTransactions: {
|
||||
id: string;
|
||||
type: FinanceEntry["type"];
|
||||
amount: number;
|
||||
date: string;
|
||||
customerName: string;
|
||||
description: string;
|
||||
}[];
|
||||
topServices: { name: string; total: number; count: number }[]; // top 5 services by revenue (qty*unitPrice)
|
||||
newCustomersMonthly: { month: string; count: number }[]; // last 6 months
|
||||
};
|
||||
|
||||
const MONTH_SHORT = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"];
|
||||
|
||||
function monthKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function monthLabel(d: Date): string {
|
||||
return MONTH_SHORT[d.getMonth()];
|
||||
}
|
||||
|
||||
export async function getDashboardData(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<DashboardData> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [customers, invoices, financeEntries, tasks, services] = await Promise.all([
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customers,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoices,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("date"),
|
||||
Query.limit(2000),
|
||||
],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tasks,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
]);
|
||||
|
||||
const customerList = customers.rows as unknown as Customer[];
|
||||
const invoiceList = invoices.rows as unknown as Invoice[];
|
||||
// Dashboard KPIs reflect company finances only — personal-scope rows belong
|
||||
// to a single user and shouldn't influence team-level metrics.
|
||||
const entryList = (financeEntries.rows as unknown as FinanceEntry[]).filter(
|
||||
(e) => (e.scope ?? "company") === "company",
|
||||
);
|
||||
const taskList = tasks.rows as unknown as Task[];
|
||||
|
||||
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
|
||||
|
||||
// ---------------- Metrics ----------------
|
||||
const now = new Date();
|
||||
const thisMonth = monthKey(now);
|
||||
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const prevMonth = monthKey(prev);
|
||||
|
||||
let monthIncome = 0;
|
||||
let prevMonthIncome = 0;
|
||||
for (const e of entryList) {
|
||||
if (e.type !== "income") continue;
|
||||
const k = monthKey(new Date(e.date));
|
||||
if (k === thisMonth) monthIncome += e.amount;
|
||||
else if (k === prevMonth) prevMonthIncome += e.amount;
|
||||
}
|
||||
|
||||
let outstanding = 0;
|
||||
let overdueCount = 0;
|
||||
const today = new Date();
|
||||
for (const inv of invoiceList) {
|
||||
const status = inv.status ?? "draft";
|
||||
if (status === "paid" || status === "cancelled") continue;
|
||||
outstanding += inv.total ?? 0;
|
||||
if (status === "overdue" || (inv.dueDate && new Date(inv.dueDate) < today)) {
|
||||
overdueCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let openTasks = 0;
|
||||
let urgentTasks = 0;
|
||||
for (const t of taskList) {
|
||||
if ((t.status ?? "todo") === "done") continue;
|
||||
// Personal scope: own assigned + unassigned. Falls back to all if no userId.
|
||||
if (currentUserId) {
|
||||
const assignee = t.assigneeId ?? "";
|
||||
if (assignee && assignee !== currentUserId) continue;
|
||||
}
|
||||
openTasks += 1;
|
||||
if ((t.priority ?? "medium") === "urgent") urgentTasks += 1;
|
||||
}
|
||||
|
||||
const activeCustomers = customerList.filter((c) => (c.status ?? "active") === "active").length;
|
||||
|
||||
// ---------------- Monthly income/expense (last 12 months) ----------------
|
||||
const monthSeries: DashboardData["monthlyIncome"] = [];
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
monthSeries.push({ month: monthLabel(d), income: 0, expense: 0 });
|
||||
}
|
||||
for (const e of entryList) {
|
||||
const ed = new Date(e.date);
|
||||
const monthsAgo =
|
||||
(now.getFullYear() - ed.getFullYear()) * 12 + (now.getMonth() - ed.getMonth());
|
||||
if (monthsAgo < 0 || monthsAgo > 11) continue;
|
||||
const idx = 11 - monthsAgo;
|
||||
if (e.type === "income") monthSeries[idx].income += e.amount;
|
||||
else if (e.type === "expense") monthSeries[idx].expense += e.amount;
|
||||
}
|
||||
|
||||
// ---------------- Top customers (by paid invoice total) ----------------
|
||||
const customerRevenue = new Map<string, number>();
|
||||
for (const inv of invoiceList) {
|
||||
if (inv.status !== "paid") continue;
|
||||
customerRevenue.set(
|
||||
inv.customerId,
|
||||
(customerRevenue.get(inv.customerId) ?? 0) + (inv.total ?? 0),
|
||||
);
|
||||
}
|
||||
const topCustomers = Array.from(customerRevenue.entries())
|
||||
.map(([id, total]) => ({ name: customerMap.get(id) ?? "—", total }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
|
||||
// ---------------- Recent transactions (last 8) ----------------
|
||||
const recentTransactions = entryList.slice(0, 8).map((e) => ({
|
||||
id: e.$id,
|
||||
type: e.type,
|
||||
amount: e.amount,
|
||||
date: e.date,
|
||||
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
|
||||
description: e.description ?? "",
|
||||
}));
|
||||
|
||||
// ---------------- Top services (by current MRR estimate) ----------------
|
||||
const svcMap = new Map<string, { name: string; total: number; count: number }>();
|
||||
for (const s of services.rows as unknown as { name: string; unitPrice?: number }[]) {
|
||||
const key = s.name;
|
||||
const entry = svcMap.get(key) ?? { name: s.name, total: 0, count: 0 };
|
||||
entry.total += s.unitPrice ?? 0;
|
||||
entry.count += 1;
|
||||
svcMap.set(key, entry);
|
||||
}
|
||||
const topServices = Array.from(svcMap.values())
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
|
||||
// ---------------- New customers per month (last 6) ----------------
|
||||
const newCustomersMonthly: DashboardData["newCustomersMonthly"] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
newCustomersMonthly.push({ month: monthLabel(d), count: 0 });
|
||||
}
|
||||
for (const c of customerList) {
|
||||
const cd = new Date(c.$createdAt);
|
||||
const monthsAgo =
|
||||
(now.getFullYear() - cd.getFullYear()) * 12 + (now.getMonth() - cd.getMonth());
|
||||
if (monthsAgo < 0 || monthsAgo > 5) continue;
|
||||
const idx = 5 - monthsAgo;
|
||||
newCustomersMonthly[idx].count += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
totalCustomers: customerList.length,
|
||||
activeCustomers,
|
||||
monthIncome,
|
||||
prevMonthIncome,
|
||||
outstanding,
|
||||
overdueCount,
|
||||
openTasks,
|
||||
urgentTasks,
|
||||
},
|
||||
monthlyIncome: monthSeries,
|
||||
topCustomers,
|
||||
recentTransactions,
|
||||
topServices,
|
||||
newCustomersMonthly,
|
||||
};
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
isPlanLimitError,
|
||||
planLimitMessage,
|
||||
requirePlanCapacity,
|
||||
} from "./plan-limits";
|
||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { FinanceActionState } from "./finance-types";
|
||||
import { financeEntrySchema } from "@/lib/validation/finance";
|
||||
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
type: formData.get("type") as "income" | "expense" | "debt" | "receivable",
|
||||
amount: String(formData.get("amount") ?? "0"),
|
||||
date: String(formData.get("date") ?? ""),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
invoiceId: String(formData.get("invoiceId") ?? ""),
|
||||
paymentMethod: formData.get("paymentMethod") as
|
||||
| "cash"
|
||||
| "transfer"
|
||||
| "card"
|
||||
| "check"
|
||||
| "other"
|
||||
| null,
|
||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function createFinanceEntryAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = financeEntrySchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePlanCapacity(ctx, "financeEntries");
|
||||
} catch (e) {
|
||||
if (isPlanLimitError(e)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: planLimitMessage(e.resource, e.limit),
|
||||
code: "PLAN_LIMIT_EXCEEDED",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...data,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "finance_entry",
|
||||
entityId: row.$id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateFinanceEntryAction(
|
||||
_prev: FinanceActionState,
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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 = financeEntrySchema.safeParse(pickFormFields(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.financeEntries,
|
||||
id,
|
||||
)) as unknown as FinanceEntry;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
id,
|
||||
data,
|
||||
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteFinanceEntryAction(
|
||||
formData: FormData,
|
||||
): Promise<FinanceActionState> {
|
||||
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.financeEntries,
|
||||
id,
|
||||
)) as unknown as FinanceEntry;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "finance_entry",
|
||||
entityId: id,
|
||||
changes: { type: existing.type, amount: existing.amount },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { canAccessRow } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||
|
||||
export async function listFinanceEntries(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<FinanceEntry[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("date"),
|
||||
Query.limit(1000),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as FinanceEntry[];
|
||||
if (!currentUserId) return rows;
|
||||
return rows.filter((r) => canAccessRow(r, currentUserId));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type BankAccount,
|
||||
type BankLoan,
|
||||
type CreditCard,
|
||||
type CreditCardStatement,
|
||||
type Customer,
|
||||
type FinanceEntry,
|
||||
type Invoice,
|
||||
type LoanInstallment,
|
||||
} from "./schema";
|
||||
|
||||
export type ReportPeriod = "month" | "quarter" | "year" | "all";
|
||||
|
||||
export type FinancialReport = {
|
||||
period: ReportPeriod;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
// KPIs
|
||||
kpi: {
|
||||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
cashPosition: number; // bank balances + receivables - loan remaining - card outstanding
|
||||
};
|
||||
// Cash composition (right now, not period-bound)
|
||||
composition: {
|
||||
bankBalances: number;
|
||||
receivables: number; // unpaid invoices total (not paid, not cancelled)
|
||||
loanRemaining: number;
|
||||
cardOutstanding: number;
|
||||
};
|
||||
// Trend (last 12 months income/expense)
|
||||
trend: { month: string; income: number; expense: number; net: number }[];
|
||||
// Top customers by paid invoice total (period-bound when period != all)
|
||||
topCustomers: { name: string; total: number }[];
|
||||
// Top expense buckets — auto-grouped by source
|
||||
expenseBreakdown: {
|
||||
invoices: number; // expenses linked to invoices? we don't track AP invoices yet — leave 0
|
||||
loans: number; // finance_entries linked through loan installments (description match heuristic)
|
||||
cards: number;
|
||||
other: number;
|
||||
};
|
||||
// Active loans
|
||||
loans: {
|
||||
id: string;
|
||||
bankName: string;
|
||||
loanName: string;
|
||||
principal: number;
|
||||
remaining: number;
|
||||
monthlyPayment: number;
|
||||
nextDue: string | null;
|
||||
}[];
|
||||
// Credit card outstanding statements
|
||||
cardStatements: {
|
||||
id: string;
|
||||
cardLabel: string;
|
||||
period: string;
|
||||
dueDate: string;
|
||||
remaining: number;
|
||||
status: "pending" | "partial" | "overdue";
|
||||
}[];
|
||||
// Outstanding (unpaid) invoices
|
||||
outstandingInvoices: {
|
||||
id: string;
|
||||
number: string;
|
||||
customerName: string;
|
||||
dueDate: string;
|
||||
total: number;
|
||||
overdue: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
const MONTH_SHORT = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"];
|
||||
|
||||
function periodBounds(p: ReportPeriod): { start: Date | null; end: Date | null } {
|
||||
const now = new Date();
|
||||
if (p === "month") {
|
||||
return {
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
if (p === "quarter") {
|
||||
const q = Math.floor(now.getMonth() / 3);
|
||||
return {
|
||||
start: new Date(now.getFullYear(), q * 3, 1),
|
||||
end: new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
if (p === "year") {
|
||||
return {
|
||||
start: new Date(now.getFullYear(), 0, 1),
|
||||
end: new Date(now.getFullYear(), 11, 31, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
function inRange(iso: string, start: Date | null, end: Date | null): boolean {
|
||||
if (!start || !end) return true;
|
||||
const t = new Date(iso).getTime();
|
||||
return t >= start.getTime() && t <= end.getTime();
|
||||
}
|
||||
|
||||
export async function getFinancialReport(
|
||||
tenantId: string,
|
||||
period: ReportPeriod = "month",
|
||||
): Promise<FinancialReport> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [
|
||||
customers,
|
||||
invoices,
|
||||
finance,
|
||||
bankAccounts,
|
||||
loans,
|
||||
installments,
|
||||
cards,
|
||||
statements,
|
||||
] = await Promise.all([
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customers,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoices,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(5000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.bankAccounts,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.bankLoans,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(5000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCards,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
})
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
]);
|
||||
|
||||
// Reports reflect COMPANY finances only — personal-scope entities are
|
||||
// private to their creator and must not flow into team-level metrics.
|
||||
const isCompany = <T extends { scope?: "company" | "personal" }>(r: T) =>
|
||||
(r.scope ?? "company") === "company";
|
||||
|
||||
const customerList = customers.rows as unknown as Customer[];
|
||||
const invoiceList = invoices.rows as unknown as Invoice[];
|
||||
const entryList = (finance.rows as unknown as FinanceEntry[]).filter(isCompany);
|
||||
const bankList = (bankAccounts.rows as unknown as BankAccount[]).filter(isCompany);
|
||||
const loanList = (loans.rows as unknown as BankLoan[]).filter(isCompany);
|
||||
const cardList = (cards.rows as unknown as CreditCard[]).filter(isCompany);
|
||||
const visibleLoanIds = new Set(loanList.map((l) => l.$id));
|
||||
const visibleCardIds = new Set(cardList.map((c) => c.$id));
|
||||
const installmentList = (installments.rows as unknown as LoanInstallment[]).filter(
|
||||
(i) => visibleLoanIds.has(i.loanId),
|
||||
);
|
||||
const statementList = (statements.rows as unknown as CreditCardStatement[]).filter(
|
||||
(s) => visibleCardIds.has(s.cardId),
|
||||
);
|
||||
|
||||
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
|
||||
const cardMap = new Map(
|
||||
cardList.map((c) => [c.$id, `${c.bankName} — ${c.cardName}${c.last4 ? ` **${c.last4}` : ""}`]),
|
||||
);
|
||||
|
||||
const { start, end } = periodBounds(period);
|
||||
|
||||
// ---------- KPIs (period-bound for income/expense, current for cash) ----------
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
for (const e of entryList) {
|
||||
if (!inRange(e.date, start, end)) continue;
|
||||
if (e.type === "income") income += e.amount;
|
||||
else if (e.type === "expense") expense += e.amount;
|
||||
}
|
||||
|
||||
// bank balance (today, not period-bound)
|
||||
const balances = new Map<string, number>();
|
||||
for (const a of bankList) balances.set(a.$id, a.openingBalance ?? 0);
|
||||
for (const e of entryList) {
|
||||
if (!e.bankAccountId) continue;
|
||||
const cur = balances.get(e.bankAccountId);
|
||||
if (cur === undefined) continue;
|
||||
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
|
||||
else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount);
|
||||
}
|
||||
const bankBalances = Array.from(balances.values()).reduce((s, n) => s + n, 0);
|
||||
|
||||
// receivables = sum of unpaid invoices
|
||||
const receivables = invoiceList.reduce((s, inv) => {
|
||||
const st = inv.status ?? "draft";
|
||||
if (st === "paid" || st === "cancelled") return s;
|
||||
return s + (inv.total ?? 0);
|
||||
}, 0);
|
||||
|
||||
// loan remaining = sum of unpaid installments
|
||||
const loanRemaining = installmentList.reduce(
|
||||
(s, i) => (i.paid ? s : s + (i.amount ?? 0)),
|
||||
0,
|
||||
);
|
||||
|
||||
// card outstanding = sum of (totalDebt - paidAmount) for non-paid statements
|
||||
const cardOutstanding = statementList.reduce(
|
||||
(s, st) =>
|
||||
st.status === "paid" ? s : s + ((st.totalDebt ?? 0) - (st.paidAmount ?? 0)),
|
||||
0,
|
||||
);
|
||||
|
||||
const cashPosition = bankBalances + receivables - loanRemaining - cardOutstanding;
|
||||
|
||||
// ---------- Trend (last 12 months always) ----------
|
||||
const trend: FinancialReport["trend"] = [];
|
||||
const now = new Date();
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
trend.push({
|
||||
month: MONTH_SHORT[d.getMonth()],
|
||||
income: 0,
|
||||
expense: 0,
|
||||
net: 0,
|
||||
});
|
||||
}
|
||||
for (const e of entryList) {
|
||||
const ed = new Date(e.date);
|
||||
const monthsAgo =
|
||||
(now.getFullYear() - ed.getFullYear()) * 12 + (now.getMonth() - ed.getMonth());
|
||||
if (monthsAgo < 0 || monthsAgo > 11) continue;
|
||||
const idx = 11 - monthsAgo;
|
||||
if (e.type === "income") trend[idx].income += e.amount;
|
||||
else if (e.type === "expense") trend[idx].expense += e.amount;
|
||||
}
|
||||
for (const t of trend) t.net = t.income - t.expense;
|
||||
|
||||
// ---------- Top customers (period-bound paid invoices) ----------
|
||||
const customerRevenue = new Map<string, number>();
|
||||
for (const inv of invoiceList) {
|
||||
if (inv.status !== "paid") continue;
|
||||
if (start && !inRange(inv.issueDate, start, end)) continue;
|
||||
customerRevenue.set(
|
||||
inv.customerId,
|
||||
(customerRevenue.get(inv.customerId) ?? 0) + (inv.total ?? 0),
|
||||
);
|
||||
}
|
||||
const topCustomers = Array.from(customerRevenue.entries())
|
||||
.map(([id, total]) => ({ name: customerMap.get(id) ?? "—", total }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 8);
|
||||
|
||||
// ---------- Expense breakdown (period-bound) ----------
|
||||
let expLoans = 0;
|
||||
let expCards = 0;
|
||||
let expOther = 0;
|
||||
const installmentEntryIds = new Set(
|
||||
installmentList.map((i) => i.financeEntryId).filter(Boolean) as string[],
|
||||
);
|
||||
const statementEntryIds = new Set(
|
||||
statementList.map((s) => s.financeEntryId).filter(Boolean) as string[],
|
||||
);
|
||||
for (const e of entryList) {
|
||||
if (e.type !== "expense") continue;
|
||||
if (!inRange(e.date, start, end)) continue;
|
||||
if (installmentEntryIds.has(e.$id)) expLoans += e.amount;
|
||||
else if (statementEntryIds.has(e.$id)) expCards += e.amount;
|
||||
else expOther += e.amount;
|
||||
}
|
||||
|
||||
// ---------- Active loans summary ----------
|
||||
const installmentsByLoan = new Map<string, LoanInstallment[]>();
|
||||
for (const i of installmentList) {
|
||||
const arr = installmentsByLoan.get(i.loanId) ?? [];
|
||||
arr.push(i);
|
||||
installmentsByLoan.set(i.loanId, arr);
|
||||
}
|
||||
const loansSummary = loanList
|
||||
.filter((l) => (l.status ?? "active") === "active")
|
||||
.map((l) => {
|
||||
const items = installmentsByLoan.get(l.$id) ?? [];
|
||||
const remaining = items.filter((i) => !i.paid).reduce((s, i) => s + i.amount, 0);
|
||||
const unpaid = items.filter((i) => !i.paid).sort(
|
||||
(a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(),
|
||||
);
|
||||
return {
|
||||
id: l.$id,
|
||||
bankName: l.bankName,
|
||||
loanName: l.loanName,
|
||||
principal: l.principal,
|
||||
remaining,
|
||||
monthlyPayment: l.monthlyPayment ?? 0,
|
||||
nextDue: unpaid[0]?.dueDate ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.remaining - a.remaining);
|
||||
|
||||
// ---------- Credit card outstanding statements ----------
|
||||
const cardStmts = statementList
|
||||
.filter((s) => s.status !== "paid")
|
||||
.map((s) => ({
|
||||
id: s.$id,
|
||||
cardLabel: cardMap.get(s.cardId) ?? "—",
|
||||
period: s.period,
|
||||
dueDate: s.dueDate,
|
||||
remaining: (s.totalDebt ?? 0) - (s.paidAmount ?? 0),
|
||||
status: (s.status ?? "pending") as "pending" | "partial" | "overdue",
|
||||
}))
|
||||
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
|
||||
|
||||
// ---------- Outstanding invoices ----------
|
||||
const today = new Date();
|
||||
const outstandingInvoices = invoiceList
|
||||
.filter((inv) => {
|
||||
const st = inv.status ?? "draft";
|
||||
return st !== "paid" && st !== "cancelled";
|
||||
})
|
||||
.map((inv) => ({
|
||||
id: inv.$id,
|
||||
number: inv.number,
|
||||
customerName: customerMap.get(inv.customerId) ?? "—",
|
||||
dueDate: inv.dueDate,
|
||||
total: inv.total ?? 0,
|
||||
overdue: new Date(inv.dueDate) < today,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.overdue !== b.overdue) return a.overdue ? -1 : 1;
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
period,
|
||||
periodStart: start ? start.toISOString() : null,
|
||||
periodEnd: end ? end.toISOString() : null,
|
||||
kpi: {
|
||||
income,
|
||||
expense,
|
||||
net: income - expense,
|
||||
cashPosition,
|
||||
},
|
||||
composition: {
|
||||
bankBalances,
|
||||
receivables,
|
||||
loanRemaining,
|
||||
cardOutstanding,
|
||||
},
|
||||
trend,
|
||||
topCustomers,
|
||||
expenseBreakdown: {
|
||||
invoices: 0,
|
||||
loans: expLoans,
|
||||
cards: expCards,
|
||||
other: expOther,
|
||||
},
|
||||
loans: loansSummary.slice(0, 8),
|
||||
cardStatements: cardStmts.slice(0, 12),
|
||||
outstandingInvoices,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type FinanceActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
code?: "PLAN_LIMIT_EXCEEDED";
|
||||
};
|
||||
|
||||
export const initialFinanceState: FinanceActionState = { ok: false };
|
||||
@@ -1,620 +0,0 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type Invoice, type InvoiceItem } from "./schema";
|
||||
|
||||
export async function listInvoices(tenantId: string): Promise<Invoice[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoices,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("issueDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Invoice[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvoice(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
): Promise<Invoice | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
id,
|
||||
)) as unknown as Invoice;
|
||||
if (row.tenantId !== tenantId) return null;
|
||||
return row;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInvoiceItems(
|
||||
tenantId: string,
|
||||
invoiceId: string,
|
||||
): Promise<InvoiceItem[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.invoiceItems,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("invoiceId", invoiceId),
|
||||
Query.orderAsc("$createdAt"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as InvoiceItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type InvoiceActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
invoiceId?: string;
|
||||
};
|
||||
|
||||
export const initialInvoiceState: InvoiceActionState = { ok: false };
|
||||
@@ -1,291 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import type { LeadActionState } from "./lead-types";
|
||||
import { DATABASE_ID, TABLES, type Lead, type LeadStatus } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import { leadSchema } from "@/lib/validation/leads";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
contactName: String(formData.get("contactName") ?? "").trim(),
|
||||
email: String(formData.get("email") ?? "").trim(),
|
||||
phone: String(formData.get("phone") ?? "").trim(),
|
||||
source: String(formData.get("source") ?? "other"),
|
||||
status: String(formData.get("status") ?? "cold"),
|
||||
estimatedValue: String(formData.get("estimatedValue") ?? ""),
|
||||
currency: String(formData.get("currency") ?? "TRY"),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
assigneeId: String(formData.get("assigneeId") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLeadAction(
|
||||
_prev: LeadActionState,
|
||||
formData: FormData,
|
||||
): Promise<LeadActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = leadSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.leads,
|
||||
ID.unique(),
|
||||
{ tenantId: ctx.tenantId, createdBy: ctx.user.id, ...parsed.data },
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "lead",
|
||||
entityId: row.$id,
|
||||
changes: { name: parsed.data.name, status: parsed.data.status },
|
||||
});
|
||||
|
||||
revalidatePath("/leads");
|
||||
return { ok: true, leadId: row.$id };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLeadAction(
|
||||
_prev: LeadActionState,
|
||||
formData: FormData,
|
||||
): Promise<LeadActionState> {
|
||||
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 = leadSchema.safeParse(pickFormFields(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.leads, id)) as unknown as Lead;
|
||||
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "lead",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
|
||||
revalidatePath("/leads");
|
||||
return { ok: true, leadId: id };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveLeadAction(
|
||||
leadId: string,
|
||||
status: LeadStatus,
|
||||
): Promise<LeadActionState> {
|
||||
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.leads, leadId)) as unknown as Lead;
|
||||
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
const prevStatus = existing.status;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
|
||||
status,
|
||||
lastContactAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Auto-create a status_change activity
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.leadActivities,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
leadId,
|
||||
type: "status_change",
|
||||
content: `${prevStatus ?? "cold"} → ${status}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(ctx.tenantId)),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
],
|
||||
);
|
||||
|
||||
revalidatePath("/leads");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLeadAction(formData: FormData): Promise<LeadActionState> {
|
||||
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.leads, id)) as unknown as Lead;
|
||||
if (existing.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.leads, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "lead",
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
});
|
||||
|
||||
revalidatePath("/leads");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertLeadToCustomerAction(formData: FormData): Promise<LeadActionState> {
|
||||
const leadId = String(formData.get("leadId") ?? "");
|
||||
if (!leadId) return { ok: false, error: "Lead ID eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
|
||||
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
const permissions = teamRowPermissions(ctx.tenantId);
|
||||
|
||||
// Create customer from lead data
|
||||
const customer = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customers,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
name: lead.contactName || lead.name,
|
||||
email: lead.email,
|
||||
phone: lead.phone,
|
||||
notes: lead.notes,
|
||||
status: "active",
|
||||
},
|
||||
permissions,
|
||||
);
|
||||
|
||||
// Update lead: mark converted + link customerId
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
|
||||
status: "converted",
|
||||
customerId: customer.$id,
|
||||
lastContactAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.leadActivities,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
leadId,
|
||||
type: "status_change",
|
||||
content: `Müşteriye dönüştürüldü → ${lead.contactName || lead.name}`,
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
permissions,
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "customer_from_lead",
|
||||
entityId: customer.$id,
|
||||
changes: { leadId, customerId: customer.$id },
|
||||
});
|
||||
|
||||
revalidatePath("/leads");
|
||||
revalidatePath("/customers");
|
||||
return { ok: true, leadId: customer.$id };
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ID, Permission, Role } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Lead, type LeadActivityType } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
export type ActivityActionState = { ok: boolean; error?: string };
|
||||
|
||||
export async function addLeadActivityAction(
|
||||
_prev: ActivityActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActivityActionState> {
|
||||
const leadId = String(formData.get("leadId") ?? "");
|
||||
const type = String(formData.get("type") ?? "note") as LeadActivityType;
|
||||
const content = String(formData.get("content") ?? "").trim();
|
||||
const occurredAt = String(formData.get("occurredAt") ?? "") || new Date().toISOString();
|
||||
|
||||
if (!leadId || !content) return { ok: false, error: "Zorunlu alanlar eksik." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
|
||||
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.leadActivities,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
leadId,
|
||||
type,
|
||||
content,
|
||||
occurredAt,
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(ctx.tenantId)),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
],
|
||||
);
|
||||
|
||||
// Update lastContactAt on the lead
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
|
||||
lastContactAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
revalidatePath("/leads");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Hata oluştu." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleFollowUpAction(
|
||||
_prev: ActivityActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActivityActionState> {
|
||||
const leadId = String(formData.get("leadId") ?? "");
|
||||
const followUpAt = String(formData.get("followUpAt") ?? "");
|
||||
const note = String(formData.get("note") ?? "").trim();
|
||||
|
||||
if (!leadId || !followUpAt) return { ok: false, error: "Tarih seçin." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const lead = (await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId)) as unknown as Lead;
|
||||
if (lead.tenantId !== ctx.tenantId) return { ok: false, error: "Erişim engellendi." };
|
||||
|
||||
const followUpDate = new Date(followUpAt);
|
||||
const endDate = new Date(followUpDate.getTime() + 60 * 60 * 1000); // +1h
|
||||
|
||||
const permissions = [
|
||||
Permission.read(Role.team(ctx.tenantId)),
|
||||
Permission.update(Role.team(ctx.tenantId)),
|
||||
Permission.delete(Role.team(ctx.tenantId, "owner")),
|
||||
];
|
||||
|
||||
// Create calendar event
|
||||
const event = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.calendarEvents,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
title: `Takip: ${lead.contactName || lead.name}`,
|
||||
description: note || `Lead takip görüşmesi — ${lead.name}`,
|
||||
start: followUpDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
allDay: false,
|
||||
leadId,
|
||||
color: "#f97316", // orange — lead events
|
||||
},
|
||||
permissions,
|
||||
);
|
||||
|
||||
// Update lead nextFollowUpAt + calendarEventId
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.leads, leadId, {
|
||||
nextFollowUpAt: followUpDate.toISOString(),
|
||||
calendarEventId: event.$id,
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.leadActivities,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
leadId,
|
||||
type: "meeting",
|
||||
content: `Takip planlandı: ${followUpDate.toLocaleString("tr-TR")}${note ? ` — ${note}` : ""}`,
|
||||
calendarEventId: event.$id,
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
permissions,
|
||||
);
|
||||
|
||||
revalidatePath("/leads");
|
||||
revalidatePath("/calendar");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Hata oluştu." };
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type Lead, type LeadActivity } from "./schema";
|
||||
|
||||
export async function listLeads(tenantId: string): Promise<Lead[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.leads,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(500)],
|
||||
});
|
||||
return result.rows as unknown as Lead[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLead(tenantId: string, leadId: string): Promise<Lead | null> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.leads, leadId);
|
||||
const lead = row as unknown as Lead;
|
||||
if (lead.tenantId !== tenantId) return null;
|
||||
return lead;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLeadActivities(tenantId: string, leadId: string): Promise<LeadActivity[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.leadActivities,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("leadId", leadId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(100),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as LeadActivity[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type LeadActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
leadId?: string;
|
||||
};
|
||||
|
||||
export const initialLeadState: LeadActionState = { ok: false };
|
||||
@@ -1,442 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Query } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type BankLoan,
|
||||
type LoanInstallment,
|
||||
} from "./schema";
|
||||
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { LoanActionState } from "./loan-types";
|
||||
import { bankLoanSchema } from "@/lib/validation/bank-loans";
|
||||
|
||||
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 pickLoanFields(formData: FormData) {
|
||||
return {
|
||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||
bankName: String(formData.get("bankName") ?? "").trim(),
|
||||
loanName: String(formData.get("loanName") ?? "").trim(),
|
||||
loanType: formData.get("loanType") as
|
||||
| "consumer"
|
||||
| "vehicle"
|
||||
| "housing"
|
||||
| "commercial"
|
||||
| "kmh"
|
||||
| "other"
|
||||
| null,
|
||||
principal: String(formData.get("principal") ?? "0"),
|
||||
interestRate: String(formData.get("interestRate") ?? "0"),
|
||||
termMonths: String(formData.get("termMonths") ?? "12"),
|
||||
startDate: String(formData.get("startDate") ?? ""),
|
||||
paymentDay: String(formData.get("paymentDay") ?? "1"),
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard amortization (eşit taksitli kredi).
|
||||
* monthlyPayment = P × r × (1+r)^n / ((1+r)^n − 1)
|
||||
* where r = monthly interest rate (decimal), n = termMonths
|
||||
*/
|
||||
function computeAmortization(
|
||||
principal: number,
|
||||
monthlyRatePct: number,
|
||||
n: number,
|
||||
): {
|
||||
monthlyPayment: number;
|
||||
schedule: Array<{ principalPart: number; interestPart: number; amount: number }>;
|
||||
} {
|
||||
const r = monthlyRatePct / 100;
|
||||
let monthlyPayment: number;
|
||||
if (r === 0) {
|
||||
monthlyPayment = principal / n;
|
||||
} else {
|
||||
const factor = Math.pow(1 + r, n);
|
||||
monthlyPayment = (principal * r * factor) / (factor - 1);
|
||||
}
|
||||
monthlyPayment = Number(monthlyPayment.toFixed(2));
|
||||
|
||||
const schedule: Array<{ principalPart: number; interestPart: number; amount: number }> = [];
|
||||
let remaining = principal;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const interestPart = Number((remaining * r).toFixed(2));
|
||||
let principalPart = Number((monthlyPayment - interestPart).toFixed(2));
|
||||
// Final installment absorbs rounding drift
|
||||
if (i === n - 1) {
|
||||
principalPart = Number(remaining.toFixed(2));
|
||||
}
|
||||
const amount = Number((interestPart + principalPart).toFixed(2));
|
||||
remaining = Number((remaining - principalPart).toFixed(2));
|
||||
schedule.push({ principalPart, interestPart, amount });
|
||||
}
|
||||
return { monthlyPayment, schedule };
|
||||
}
|
||||
|
||||
function shiftMonth(date: Date, monthsAhead: number, paymentDay: number): Date {
|
||||
const d = new Date(date.getFullYear(), date.getMonth() + monthsAhead, 1);
|
||||
// clamp paymentDay to last day of that month
|
||||
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||
d.setDate(Math.min(paymentDay, lastDay));
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function createLoanAction(
|
||||
_prev: LoanActionState,
|
||||
formData: FormData,
|
||||
): Promise<LoanActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = bankLoanSchema.safeParse(pickLoanFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
const { schedule, monthlyPayment } = computeAmortization(
|
||||
parsed.data.principal,
|
||||
parsed.data.interestRate,
|
||||
parsed.data.termMonths,
|
||||
);
|
||||
|
||||
let loanId: string | null = null;
|
||||
const admin = createAdminClient();
|
||||
// Installments inherit the loan's scope so personal-loan installments stay
|
||||
// hidden from the rest of the team too.
|
||||
const rowPerms = scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope);
|
||||
try {
|
||||
const loan = await admin.tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.bankLoans,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
bankAccountId: parsed.data.bankAccountId,
|
||||
bankName: parsed.data.bankName,
|
||||
loanName: parsed.data.loanName,
|
||||
loanType: parsed.data.loanType,
|
||||
principal: parsed.data.principal,
|
||||
interestRate: parsed.data.interestRate,
|
||||
termMonths: parsed.data.termMonths,
|
||||
monthlyPayment,
|
||||
startDate: toIso(parsed.data.startDate),
|
||||
paymentDay: parsed.data.paymentDay,
|
||||
status: "active",
|
||||
notes: parsed.data.notes,
|
||||
scope: parsed.data.scope,
|
||||
},
|
||||
rowPerms,
|
||||
);
|
||||
loanId = loan.$id;
|
||||
|
||||
const start = new Date(toIso(parsed.data.startDate));
|
||||
for (let i = 0; i < parsed.data.termMonths; i++) {
|
||||
const due = shiftMonth(start, i + 1, parsed.data.paymentDay ?? 1);
|
||||
const slice = schedule[i];
|
||||
await admin.tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.loanInstallments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
loanId,
|
||||
installmentNo: i + 1,
|
||||
dueDate: due.toISOString(),
|
||||
amount: slice.amount,
|
||||
principalPart: slice.principalPart,
|
||||
interestPart: slice.interestPart,
|
||||
paid: false,
|
||||
},
|
||||
rowPerms,
|
||||
);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "bank_loan",
|
||||
entityId: loan.$id,
|
||||
changes: { ...parsed.data, monthlyPayment, installments: parsed.data.termMonths },
|
||||
});
|
||||
} catch (e) {
|
||||
if (loanId) {
|
||||
// Best-effort rollback: delete partially-created installments + loan
|
||||
try {
|
||||
const partial = await admin.tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("loanId", loanId),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const r of partial.rows) {
|
||||
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
|
||||
}
|
||||
await admin.tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, loanId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/loans");
|
||||
return { ok: true, loanId };
|
||||
}
|
||||
|
||||
export async function deleteLoanAction(formData: FormData): Promise<LoanActionState> {
|
||||
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.bankLoans,
|
||||
id,
|
||||
)) as unknown as BankLoan;
|
||||
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Delete installments first
|
||||
const installments = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("loanId", id),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const r of installments.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.loanInstallments, r.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.bankLoans, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "bank_loan",
|
||||
entityId: id,
|
||||
changes: { loanName: existing.loanName, installments: installments.rows.length },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/loans");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function payInstallmentAction(formData: FormData): Promise<LoanActionState> {
|
||||
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.loanInstallments,
|
||||
id,
|
||||
)) as unknown as LoanInstallment;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
if (existing.paid) {
|
||||
return { ok: false, error: "Bu taksit zaten ödenmiş." };
|
||||
}
|
||||
|
||||
const loan = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.bankLoans,
|
||||
existing.loanId,
|
||||
)) as unknown as BankLoan;
|
||||
if (!canAccessRow(loan, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const loanScope = loan.scope ?? "company";
|
||||
// Create finance entry: expense, linked. Inherit loan's scope so personal
|
||||
// installments don't leak into company finance.
|
||||
const fe = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
type: "expense",
|
||||
amount: existing.amount,
|
||||
date: new Date().toISOString(),
|
||||
description: `${loan.bankName} — ${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
|
||||
bankAccountId: loan.bankAccountId,
|
||||
scope: loanScope,
|
||||
},
|
||||
scopedRowPermissions(ctx.tenantId, ctx.user.id, loanScope),
|
||||
);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
|
||||
paid: true,
|
||||
paidAt: new Date().toISOString(),
|
||||
financeEntryId: fe.$id,
|
||||
});
|
||||
|
||||
// If this was the last unpaid one, mark loan closed
|
||||
const remaining = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("loanId", existing.loanId),
|
||||
Query.equal("paid", false),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
if (remaining.rows.length === 0) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
|
||||
status: "closed",
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "loan_installment",
|
||||
entityId: id,
|
||||
changes: { paid: true, financeEntryId: fe.$id, amount: existing.amount },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/loans");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function unpayInstallmentAction(
|
||||
formData: FormData,
|
||||
): Promise<LoanActionState> {
|
||||
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.loanInstallments,
|
||||
id,
|
||||
)) as unknown as LoanInstallment;
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
if (!existing.paid) return { ok: true };
|
||||
|
||||
// Verify the parent loan is also accessible (handles personal scope).
|
||||
const parent = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.bankLoans,
|
||||
existing.loanId,
|
||||
)) as unknown as BankLoan;
|
||||
if (!canAccessRow(parent, ctx.user.id)) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
if (existing.financeEntryId) {
|
||||
try {
|
||||
await tablesDB.deleteRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
existing.financeEntryId,
|
||||
);
|
||||
} catch {
|
||||
/* ignore: maybe already gone */
|
||||
}
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
|
||||
paid: false,
|
||||
paidAt: null,
|
||||
financeEntryId: null,
|
||||
});
|
||||
|
||||
// If loan was closed, reopen it
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.bankLoans, existing.loanId, {
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "loan_installment",
|
||||
entityId: id,
|
||||
changes: { paid: false },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/finance/loans");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { canAccessRow } from "./scope-permissions";
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type BankLoan,
|
||||
type LoanInstallment,
|
||||
} from "./schema";
|
||||
|
||||
export async function listLoans(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<BankLoan[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.bankLoans,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
});
|
||||
const rows = result.rows as unknown as BankLoan[];
|
||||
if (!currentUserId) return rows;
|
||||
return rows.filter((r) => canAccessRow(r, currentUserId));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInstallmentsForLoan(
|
||||
tenantId: string,
|
||||
loanId: string,
|
||||
): Promise<LoanInstallment[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("loanId", loanId),
|
||||
Query.orderAsc("installmentNo"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as LoanInstallment[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls all installments and filters to those whose parent loan is visible to the user.
|
||||
*/
|
||||
export async function listAllInstallments(
|
||||
tenantId: string,
|
||||
currentUserId?: string,
|
||||
): Promise<LoanInstallment[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const [allInst, allLoans] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.bankLoans,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(500)],
|
||||
}),
|
||||
]);
|
||||
const visibleLoanIds = new Set(
|
||||
(allLoans.rows as unknown as BankLoan[])
|
||||
.filter((l) => !currentUserId || canAccessRow(l, currentUserId))
|
||||
.map((l) => l.$id),
|
||||
);
|
||||
return (allInst.rows as unknown as LoanInstallment[]).filter((i) =>
|
||||
visibleLoanIds.has(i.loanId),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type LoanActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
loanId?: string;
|
||||
};
|
||||
|
||||
export const initialLoanState: LoanActionState = { ok: false };
|
||||
@@ -1,123 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
|
||||
import type { TenantContext } from "./tenant-guard";
|
||||
|
||||
export type PlanResource = "customers" | "financeEntries" | "software" | "members";
|
||||
|
||||
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
|
||||
|
||||
const INF = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
|
||||
free: {
|
||||
customers: 50,
|
||||
financeEntries: 100,
|
||||
software: 5,
|
||||
members: 1,
|
||||
},
|
||||
pro: {
|
||||
customers: INF,
|
||||
financeEntries: INF,
|
||||
software: INF,
|
||||
members: INF,
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_LABELS: Record<PlanResource, string> = {
|
||||
customers: "müşteri",
|
||||
financeEntries: "finans kaydı",
|
||||
software: "yazılım",
|
||||
members: "ekip üyesi",
|
||||
};
|
||||
|
||||
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
if (plan === "pro") {
|
||||
const expires = ctx.settings?.planExpiresAt;
|
||||
if (expires && new Date(expires).getTime() < Date.now()) {
|
||||
return "free";
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function countResource(
|
||||
tenantId: string,
|
||||
resource: PlanResource,
|
||||
): Promise<number> {
|
||||
const { tablesDB, teams } = createAdminClient();
|
||||
|
||||
if (resource === "members") {
|
||||
const result = await teams.listMemberships(tenantId);
|
||||
return result.total;
|
||||
}
|
||||
|
||||
const tableMap: Record<Exclude<PlanResource, "members">, string> = {
|
||||
customers: TABLES.customers,
|
||||
financeEntries: TABLES.financeEntries,
|
||||
software: TABLES.software,
|
||||
};
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: tableMap[resource],
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
});
|
||||
return result.total;
|
||||
}
|
||||
|
||||
export type PlanUsage = {
|
||||
plan: TenantPlan;
|
||||
usage: Record<PlanResource, { used: number; limit: number; reached: boolean }>;
|
||||
};
|
||||
|
||||
export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const limits = PLAN_LIMITS[plan];
|
||||
|
||||
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
|
||||
const counts = await Promise.all(resources.map((r) => countResource(ctx.tenantId, r)));
|
||||
|
||||
const usage = {} as PlanUsage["usage"];
|
||||
resources.forEach((r, i) => {
|
||||
const used = counts[i];
|
||||
const limit = limits[r];
|
||||
usage[r] = { used, limit, reached: used >= limit };
|
||||
});
|
||||
|
||||
return { plan, usage };
|
||||
}
|
||||
|
||||
export class PlanLimitError extends Error {
|
||||
code = PLAN_LIMIT_EXCEEDED;
|
||||
constructor(public resource: PlanResource, public limit: number) {
|
||||
super(`Plan limit reached for ${resource} (${limit})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePlanCapacity(
|
||||
ctx: TenantContext,
|
||||
resource: PlanResource,
|
||||
): Promise<void> {
|
||||
const plan = getEffectivePlan(ctx);
|
||||
const limit = PLAN_LIMITS[plan][resource];
|
||||
if (limit === INF) return;
|
||||
|
||||
const used = await countResource(ctx.tenantId, resource);
|
||||
if (used >= limit) {
|
||||
throw new PlanLimitError(resource, limit);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlanLimitError(e: unknown): e is PlanLimitError {
|
||||
return e instanceof PlanLimitError;
|
||||
}
|
||||
|
||||
export function planLimitMessage(resource: PlanResource, limit: number): string {
|
||||
const label = RESOURCE_LABELS[resource];
|
||||
return `Ücretsiz planda en fazla ${limit} ${label} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type CardBrand, type SavedCard } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
|
||||
const VALID_BRANDS: CardBrand[] = ["visa", "mastercard", "amex", "troy", "unknown"];
|
||||
|
||||
function teamCardPermissions(tenantId: string) {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId, "owner")),
|
||||
Permission.read(Role.team(tenantId, "admin")),
|
||||
Permission.update(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
];
|
||||
}
|
||||
|
||||
async function clearOtherDefaults(tenantId: string, exceptId?: string): Promise<void> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.savedCards,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("isDefault", true),
|
||||
Query.limit(20),
|
||||
],
|
||||
});
|
||||
for (const row of result.rows) {
|
||||
if (exceptId && row.$id === exceptId) continue;
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, row.$id, {
|
||||
isDefault: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type SaveCardInput = {
|
||||
brand: string;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
holderName: string;
|
||||
makeDefault: boolean;
|
||||
};
|
||||
|
||||
export async function persistCardFromMockCheckout(input: SaveCardInput): Promise<void> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const brand = (VALID_BRANDS as string[]).includes(input.brand)
|
||||
? (input.brand as CardBrand)
|
||||
: "unknown";
|
||||
const last4 = input.last4.replace(/\D/g, "").slice(-4).padStart(4, "0");
|
||||
|
||||
if (last4.length !== 4) throw new Error("Geçersiz kart son 4 hanesi.");
|
||||
if (input.expiryMonth < 1 || input.expiryMonth > 12) throw new Error("Geçersiz ay.");
|
||||
if (input.expiryYear < 2026 || input.expiryYear > 2099) throw new Error("Geçersiz yıl.");
|
||||
|
||||
if (input.makeDefault) {
|
||||
await clearOtherDefaults(ctx.tenantId);
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.savedCards,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
brand,
|
||||
last4,
|
||||
expiryMonth: input.expiryMonth,
|
||||
expiryYear: input.expiryYear,
|
||||
holderName: input.holderName.trim().slice(0, 128) || undefined,
|
||||
provider: "mock",
|
||||
isDefault: input.makeDefault,
|
||||
},
|
||||
teamCardPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "saved_card",
|
||||
entityId: row.$id,
|
||||
changes: { brand, last4, isDefault: input.makeDefault },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
}
|
||||
|
||||
export async function setDefaultCardAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) throw new Error("ID eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.savedCards,
|
||||
id,
|
||||
)) as unknown as SavedCard;
|
||||
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
|
||||
|
||||
await clearOtherDefaults(ctx.tenantId, id);
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, id, { isDefault: true });
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "saved_card",
|
||||
entityId: id,
|
||||
changes: { isDefault: true },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
}
|
||||
|
||||
export async function removeCardAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
if (!id) throw new Error("ID eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const existing = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.savedCards,
|
||||
id,
|
||||
)) as unknown as SavedCard;
|
||||
if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.savedCards, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "saved_card",
|
||||
entityId: id,
|
||||
changes: { last4: existing.last4 },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type SavedCard } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export async function listSavedCards(tenantId: string): Promise<SavedCard[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.savedCards,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("isDefault"),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(20),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as SavedCard[];
|
||||
}
|
||||
|
||||
export async function getDefaultCard(tenantId: string): Promise<SavedCard | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.savedCards,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("isDefault", true),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
return (result.rows[0] as unknown as SavedCard) ?? null;
|
||||
}
|
||||
+193
-326
@@ -1,31 +1,20 @@
|
||||
export const DATABASE_ID = "isletmem";
|
||||
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID ?? "kovakemlak-db";
|
||||
|
||||
export const BUCKETS = {
|
||||
propertyImages: "property-images",
|
||||
tenantLogos: "tenant-logos",
|
||||
} as const;
|
||||
|
||||
export const TABLES = {
|
||||
tenantSettings: "tenant_settings",
|
||||
properties: "properties",
|
||||
customers: "customers",
|
||||
services: "services",
|
||||
software: "software",
|
||||
customerSoftware: "customer_software",
|
||||
calendarEvents: "calendar_events",
|
||||
tasks: "tasks",
|
||||
financeEntries: "finance_entries",
|
||||
invoices: "invoices",
|
||||
invoiceItems: "invoice_items",
|
||||
auditLogs: "audit_logs",
|
||||
customerSearches: "customer_searches",
|
||||
propertyMatches: "property_matches",
|
||||
presentations: "presentations",
|
||||
investors: "investors",
|
||||
activities: "activities",
|
||||
tenantSettings: "tenant_settings",
|
||||
inviteLinks: "invite_links",
|
||||
bankAccounts: "bank_accounts",
|
||||
bankLoans: "bank_loans",
|
||||
loanInstallments: "loan_installments",
|
||||
creditCards: "credit_cards",
|
||||
creditCardStatements: "credit_card_statements",
|
||||
subscriptionPayments: "subscription_payments",
|
||||
savedCards: "saved_cards",
|
||||
leads: "leads",
|
||||
leadActivities: "lead_activities",
|
||||
} as const;
|
||||
|
||||
export type TableId = (typeof TABLES)[keyof typeof TABLES];
|
||||
@@ -43,318 +32,196 @@ export type SystemRow = {
|
||||
type Row = SystemRow;
|
||||
|
||||
export type TenantRole = "owner" | "admin" | "member";
|
||||
|
||||
export type TenantPlan = "free" | "pro";
|
||||
|
||||
export interface TenantSettings extends Row {
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
companyTaxId?: string;
|
||||
companyAddress?: string;
|
||||
companyEmail?: string;
|
||||
companyPhone?: string;
|
||||
logo?: string;
|
||||
defaultVatRate?: number;
|
||||
invoicePrefix?: string;
|
||||
invoiceCounter?: number;
|
||||
plan?: TenantPlan;
|
||||
planStartedAt?: string;
|
||||
planExpiresAt?: string;
|
||||
lastPaymentId?: string;
|
||||
}
|
||||
|
||||
export type CustomerStatus = "active" | "passive";
|
||||
|
||||
export interface Customer extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
status?: CustomerStatus;
|
||||
}
|
||||
|
||||
export type BillingPeriod = "monthly" | "yearly" | "onetime";
|
||||
|
||||
export interface Service extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
unitPrice: number;
|
||||
currency?: string;
|
||||
recurring?: boolean;
|
||||
billingPeriod?: BillingPeriod;
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export interface Software extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
defaultFee?: number;
|
||||
}
|
||||
|
||||
export interface CustomerSoftware extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
customerId: string;
|
||||
softwareId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
fee?: number;
|
||||
billingPeriod?: BillingPeriod;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay?: boolean;
|
||||
customerId?: string;
|
||||
leadId?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export type LeadStatus = "cold" | "warm" | "hot" | "converted" | "lost";
|
||||
export type LeadSource = "website" | "social" | "referral" | "cold_call" | "event" | "other";
|
||||
export type LeadActivityType = "note" | "call" | "meeting" | "email" | "status_change";
|
||||
|
||||
export interface Lead extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
name: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
source?: LeadSource;
|
||||
status?: LeadStatus;
|
||||
estimatedValue?: number;
|
||||
currency?: string;
|
||||
notes?: string;
|
||||
assigneeId?: string;
|
||||
lastContactAt?: string;
|
||||
nextFollowUpAt?: string;
|
||||
calendarEventId?: string;
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
export interface LeadActivity extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
leadId: string;
|
||||
type: LeadActivityType;
|
||||
content: string;
|
||||
calendarEventId?: string;
|
||||
occurredAt?: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "done";
|
||||
export type TaskPriority = "low" | "medium" | "high" | "urgent";
|
||||
|
||||
export interface Task extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
dueDate?: string;
|
||||
assigneeId?: string;
|
||||
customerId?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type FinanceType = "income" | "expense" | "debt" | "receivable";
|
||||
export type PaymentMethod = "cash" | "transfer" | "card" | "check" | "other";
|
||||
|
||||
export interface FinanceEntry extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
type: FinanceType;
|
||||
amount: number;
|
||||
date: string;
|
||||
description?: string;
|
||||
customerId?: string;
|
||||
invoiceId?: string;
|
||||
paymentMethod?: PaymentMethod;
|
||||
bankAccountId?: string;
|
||||
scope?: "company" | "personal";
|
||||
}
|
||||
|
||||
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
|
||||
|
||||
export interface Invoice extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
number: string;
|
||||
customerId: string;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
status?: InvoiceStatus;
|
||||
subtotal?: number;
|
||||
vatTotal?: number;
|
||||
total?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceItem extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
invoiceId: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
vatRate?: number;
|
||||
lineTotal: number;
|
||||
}
|
||||
|
||||
export type AuditAction = "create" | "update" | "delete";
|
||||
|
||||
export interface AuditLog extends Row {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
action: AuditAction;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
changes?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export type FinanceScope = "company" | "personal";
|
||||
|
||||
export interface BankAccount extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
bankName: string;
|
||||
accountName: string;
|
||||
iban?: string;
|
||||
openingBalance?: number;
|
||||
notes?: string;
|
||||
archived?: boolean;
|
||||
scope?: FinanceScope;
|
||||
}
|
||||
|
||||
export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
|
||||
export type LoanStatus = "active" | "closed" | "defaulted";
|
||||
|
||||
export interface BankLoan extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
bankAccountId?: string;
|
||||
bankName: string;
|
||||
loanName: string;
|
||||
loanType?: LoanType;
|
||||
principal: number;
|
||||
interestRate: number; // monthly nominal %
|
||||
termMonths: number;
|
||||
monthlyPayment?: number;
|
||||
startDate: string;
|
||||
paymentDay?: number;
|
||||
status?: LoanStatus;
|
||||
notes?: string;
|
||||
scope?: FinanceScope;
|
||||
}
|
||||
|
||||
export interface LoanInstallment extends Row {
|
||||
tenantId: string;
|
||||
loanId: string;
|
||||
installmentNo: number;
|
||||
dueDate: string;
|
||||
amount: number;
|
||||
principalPart?: number;
|
||||
interestPart?: number;
|
||||
paid?: boolean;
|
||||
paidAt?: string;
|
||||
financeEntryId?: string;
|
||||
}
|
||||
|
||||
export interface CreditCard extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
bankName: string;
|
||||
cardName: string;
|
||||
last4?: string;
|
||||
creditLimit?: number;
|
||||
statementDay?: number;
|
||||
dueDay?: number;
|
||||
interestRate?: number;
|
||||
bankAccountId?: string;
|
||||
archived?: boolean;
|
||||
notes?: string;
|
||||
scope?: FinanceScope;
|
||||
}
|
||||
|
||||
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
|
||||
|
||||
export interface CreditCardStatement extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
cardId: string;
|
||||
period: string; // YYYY-MM
|
||||
statementDate: string;
|
||||
dueDate: string;
|
||||
totalDebt: number;
|
||||
minimumPayment?: number;
|
||||
paidAmount?: number;
|
||||
status?: StatementStatus;
|
||||
financeEntryId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
|
||||
export type SubscriptionProvider = "mock" | "shopier" | "polar";
|
||||
|
||||
export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
|
||||
|
||||
export interface SavedCard extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
brand?: CardBrand;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
holderName?: string;
|
||||
providerToken?: string;
|
||||
provider?: SubscriptionProvider;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionPayment extends Row {
|
||||
tenantId: string;
|
||||
createdBy: string;
|
||||
orderId: string;
|
||||
plan: TenantPlan;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
status?: SubscriptionStatus;
|
||||
provider?: SubscriptionProvider;
|
||||
providerPayload?: string;
|
||||
processedAt?: string;
|
||||
}
|
||||
|
||||
export type InviteRole = "admin" | "member";
|
||||
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
|
||||
|
||||
export interface InviteLink extends Row {
|
||||
export interface InviteLink extends SystemRow {
|
||||
tenantId: string;
|
||||
code: string;
|
||||
email: string;
|
||||
role?: InviteRole;
|
||||
status?: InviteStatus;
|
||||
role: InviteRole;
|
||||
status: "pending" | "accepted" | "expired" | "cancelled";
|
||||
invitedBy: string;
|
||||
expiresAt?: string;
|
||||
expiresAt: string;
|
||||
acceptedAt?: string;
|
||||
acceptedBy?: string;
|
||||
}
|
||||
|
||||
export type PropertyType = "daire" | "villa" | "arsa" | "dukkan" | "ofis" | "depo";
|
||||
export type ListingType = "satilik" | "kiralik";
|
||||
export type PropertyStatus = "aktif" | "pasif" | "satildi" | "kiralandit";
|
||||
export type CustomerType = "alici" | "kiraci" | "yatirimci";
|
||||
export type ActivityType = "gorusme" | "teklif" | "ziyaret" | "arama" | "not";
|
||||
|
||||
export interface Property extends Row {
|
||||
tenantId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
propertyType: PropertyType;
|
||||
listingType: ListingType;
|
||||
status: PropertyStatus;
|
||||
price: number;
|
||||
currency?: string;
|
||||
roomCount?: string;
|
||||
grossM2?: number;
|
||||
netM2?: number;
|
||||
floor?: number;
|
||||
totalFloors?: number;
|
||||
buildingAge?: number;
|
||||
city: string;
|
||||
district?: string;
|
||||
neighborhood?: string;
|
||||
address?: string;
|
||||
mapLat?: number;
|
||||
mapLng?: number;
|
||||
featuresJson?: string;
|
||||
imageIds?: string;
|
||||
createdBy: string;
|
||||
assigneeId?: string;
|
||||
}
|
||||
|
||||
export interface Customer extends Row {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
type: CustomerType;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface CustomerSearch extends Row {
|
||||
tenantId: string;
|
||||
customerId: string;
|
||||
listingType?: ListingType;
|
||||
propertyTypes?: string;
|
||||
roomCounts?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
minM2?: number;
|
||||
maxM2?: number;
|
||||
cities?: string;
|
||||
districts?: string;
|
||||
featuresJson?: string;
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface PropertyMatch extends Row {
|
||||
tenantId: string;
|
||||
propertyId: string;
|
||||
customerId: string;
|
||||
searchId: string;
|
||||
notified?: boolean;
|
||||
viewedAt?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface Presentation extends Row {
|
||||
tenantId: string;
|
||||
title: string;
|
||||
customerId?: string;
|
||||
propertyIds: string;
|
||||
shareToken: string;
|
||||
expiresAt?: string;
|
||||
viewCount?: number;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface Investor extends Row {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
budget?: number;
|
||||
currency?: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface Activity extends Row {
|
||||
tenantId: string;
|
||||
customerId?: string;
|
||||
propertyId?: string;
|
||||
type: ActivityType;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string;
|
||||
completedAt?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface TenantSettings extends Row {
|
||||
tenantId: string;
|
||||
officeName?: string;
|
||||
logo?: string;
|
||||
defaultCurrency?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type PropertyFeature =
|
||||
| "balkon"
|
||||
| "otopark"
|
||||
| "asansor"
|
||||
| "havuz"
|
||||
| "spor_salonu"
|
||||
| "guvenlik"
|
||||
| "site_ici"
|
||||
| "esyali"
|
||||
| "merkezi_isitma"
|
||||
| "dogalgaz"
|
||||
| "deprem_yalitim"
|
||||
| "bahce";
|
||||
|
||||
export const ROOM_COUNT_OPTIONS = [
|
||||
"Stüdyo",
|
||||
"1+0",
|
||||
"1+1",
|
||||
"2+1",
|
||||
"3+1",
|
||||
"4+1",
|
||||
"4+2",
|
||||
"5+1",
|
||||
"5+2",
|
||||
"6+",
|
||||
] as const;
|
||||
|
||||
export const PROPERTY_TYPE_LABELS: Record<PropertyType, string> = {
|
||||
daire: "Daire",
|
||||
villa: "Villa",
|
||||
arsa: "Arsa",
|
||||
dukkan: "Dükkan",
|
||||
ofis: "Ofis",
|
||||
depo: "Depo",
|
||||
};
|
||||
|
||||
export const LISTING_TYPE_LABELS: Record<ListingType, string> = {
|
||||
satilik: "Satılık",
|
||||
kiralik: "Kiralık",
|
||||
};
|
||||
|
||||
export const PROPERTY_STATUS_LABELS: Record<PropertyStatus, string> = {
|
||||
aktif: "Aktif",
|
||||
pasif: "Pasif",
|
||||
satildi: "Satıldı",
|
||||
kiralandit: "Kiralandı",
|
||||
};
|
||||
|
||||
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
|
||||
alici: "Alıcı",
|
||||
kiraci: "Kiracı",
|
||||
yatirimci: "Yatırımcı",
|
||||
};
|
||||
|
||||
export const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||
gorusme: "Görüşme",
|
||||
teklif: "Teklif",
|
||||
ziyaret: "Ziyaret",
|
||||
arama: "Arama",
|
||||
not: "Not",
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Permission, Role } from "node-appwrite";
|
||||
|
||||
import type { FinanceScope } from "./schema";
|
||||
|
||||
/**
|
||||
* Returns row-level permissions for finance-related entities.
|
||||
*
|
||||
* - `company`: visible to the whole tenant team. Owner/admin can delete.
|
||||
* - `personal`: visible/editable/deletable only by the creator.
|
||||
*
|
||||
* The Appwrite "users" table-level perms still gate writes; these row-level
|
||||
* perms gate reads and per-row mutations.
|
||||
*/
|
||||
export function scopedRowPermissions(
|
||||
tenantId: string,
|
||||
createdBy: string,
|
||||
scope: FinanceScope,
|
||||
): string[] {
|
||||
if (scope === "personal") {
|
||||
return [
|
||||
Permission.read(Role.user(createdBy)),
|
||||
Permission.update(Role.user(createdBy)),
|
||||
Permission.delete(Role.user(createdBy)),
|
||||
];
|
||||
}
|
||||
return [
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId)),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
];
|
||||
}
|
||||
|
||||
export function normalizeScope(v: unknown): FinanceScope {
|
||||
return v === "personal" ? "personal" : "company";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current user is allowed to read the row.
|
||||
* - company-scope: any team member
|
||||
* - personal-scope: only the creator
|
||||
*/
|
||||
export function canAccessRow(
|
||||
row: { scope?: FinanceScope; createdBy?: string },
|
||||
currentUserId: string,
|
||||
): boolean {
|
||||
if ((row.scope ?? "company") === "personal") {
|
||||
return row.createdBy === currentUserId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CalendarEvent,
|
||||
type Customer,
|
||||
type FinanceEntry,
|
||||
type Invoice,
|
||||
type Service,
|
||||
type Software,
|
||||
type Task,
|
||||
} from "./schema";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
export type SearchHit = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
group: SearchGroup;
|
||||
};
|
||||
|
||||
export type SearchGroup =
|
||||
| "customers"
|
||||
| "invoices"
|
||||
| "tasks"
|
||||
| "services"
|
||||
| "software"
|
||||
| "events"
|
||||
| "finance";
|
||||
|
||||
export type SearchResults = Record<SearchGroup, SearchHit[]>;
|
||||
|
||||
const PAGE_LIMIT = 200; // per entity, plenty for search-time scan
|
||||
const MAX_HITS_PER_GROUP = 8;
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
debt: "Borç",
|
||||
receivable: "Alacak",
|
||||
};
|
||||
|
||||
function tryMatch(haystack: string | undefined | null, needle: string): boolean {
|
||||
if (!haystack) return false;
|
||||
return haystack.toLocaleLowerCase("tr-TR").includes(needle);
|
||||
}
|
||||
|
||||
export async function globalSearchAction(rawQuery: string): Promise<SearchResults> {
|
||||
const empty: SearchResults = {
|
||||
customers: [],
|
||||
invoices: [],
|
||||
tasks: [],
|
||||
services: [],
|
||||
software: [],
|
||||
events: [],
|
||||
finance: [],
|
||||
};
|
||||
|
||||
const q = rawQuery.trim().toLocaleLowerCase("tr-TR");
|
||||
if (!q || q.length < 2) return empty;
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const tenantQ = [Query.equal("tenantId", ctx.tenantId), Query.limit(PAGE_LIMIT)];
|
||||
|
||||
const [customers, invoices, tasks, services, software, events, finance] =
|
||||
await Promise.all([
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.invoices, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tasks, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.services, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.software, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.calendarEvents, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.financeEntries, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
]);
|
||||
|
||||
const customerMap = new Map<string, string>();
|
||||
for (const c of customers.rows as unknown as Customer[]) {
|
||||
customerMap.set(c.$id, c.name);
|
||||
}
|
||||
|
||||
// ---------- Customers ----------
|
||||
const customerHits: SearchHit[] = [];
|
||||
for (const c of customers.rows as unknown as Customer[]) {
|
||||
if (
|
||||
tryMatch(c.name, q) ||
|
||||
tryMatch(c.email, q) ||
|
||||
tryMatch(c.phone, q) ||
|
||||
tryMatch(c.taxId, q)
|
||||
) {
|
||||
customerHits.push({
|
||||
id: c.$id,
|
||||
title: c.name,
|
||||
subtitle: c.email || c.phone || c.taxId || undefined,
|
||||
url: "/customers",
|
||||
group: "customers",
|
||||
});
|
||||
if (customerHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Invoices ----------
|
||||
const invoiceHits: SearchHit[] = [];
|
||||
for (const inv of invoices.rows as unknown as Invoice[]) {
|
||||
if (
|
||||
tryMatch(inv.number, q) ||
|
||||
tryMatch(inv.notes, q) ||
|
||||
tryMatch(customerMap.get(inv.customerId), q)
|
||||
) {
|
||||
invoiceHits.push({
|
||||
id: inv.$id,
|
||||
title: inv.number,
|
||||
subtitle: customerMap.get(inv.customerId) ?? undefined,
|
||||
url: `/invoices/${inv.$id}`,
|
||||
group: "invoices",
|
||||
});
|
||||
if (invoiceHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tasks ----------
|
||||
const taskHits: SearchHit[] = [];
|
||||
for (const t of tasks.rows as unknown as Task[]) {
|
||||
if (tryMatch(t.title, q) || tryMatch(t.description, q)) {
|
||||
taskHits.push({
|
||||
id: t.$id,
|
||||
title: t.title,
|
||||
subtitle: t.description ? t.description.slice(0, 80) : undefined,
|
||||
url: "/tasks",
|
||||
group: "tasks",
|
||||
});
|
||||
if (taskHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Services ----------
|
||||
const serviceHits: SearchHit[] = [];
|
||||
for (const s of services.rows as unknown as Service[]) {
|
||||
if (tryMatch(s.name, q) || tryMatch(s.description, q)) {
|
||||
serviceHits.push({
|
||||
id: s.$id,
|
||||
title: s.name,
|
||||
subtitle: customerMap.get(s.customerId) ?? undefined,
|
||||
url: "/services",
|
||||
group: "services",
|
||||
});
|
||||
if (serviceHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Software ----------
|
||||
const softwareHits: SearchHit[] = [];
|
||||
for (const s of software.rows as unknown as Software[]) {
|
||||
if (tryMatch(s.name, q) || tryMatch(s.version, q) || tryMatch(s.description, q)) {
|
||||
softwareHits.push({
|
||||
id: s.$id,
|
||||
title: s.name,
|
||||
subtitle: s.version ? `v${s.version}` : undefined,
|
||||
url: "/software",
|
||||
group: "software",
|
||||
});
|
||||
if (softwareHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Calendar events ----------
|
||||
const eventHits: SearchHit[] = [];
|
||||
for (const e of events.rows as unknown as CalendarEvent[]) {
|
||||
if (tryMatch(e.title, q) || tryMatch(e.description, q)) {
|
||||
eventHits.push({
|
||||
id: e.$id,
|
||||
title: e.title,
|
||||
subtitle: new Date(e.start).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
url: "/calendar",
|
||||
group: "events",
|
||||
});
|
||||
if (eventHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Finance entries ----------
|
||||
const financeHits: SearchHit[] = [];
|
||||
for (const e of finance.rows as unknown as FinanceEntry[]) {
|
||||
const amountStr = e.amount.toString();
|
||||
if (
|
||||
tryMatch(e.description, q) ||
|
||||
tryMatch(customerMap.get(e.customerId ?? ""), q) ||
|
||||
tryMatch(amountStr, q)
|
||||
) {
|
||||
financeHits.push({
|
||||
id: e.$id,
|
||||
title: `${TYPE_LABEL[e.type]} — ${e.amount.toLocaleString("tr-TR", { minimumFractionDigits: 2 })} ₺`,
|
||||
subtitle:
|
||||
(customerMap.get(e.customerId ?? "") || e.description || "").slice(0, 80) ||
|
||||
undefined,
|
||||
url: "/finance",
|
||||
group: "finance",
|
||||
});
|
||||
if (financeHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customerHits,
|
||||
invoices: invoiceHits,
|
||||
tasks: taskHits,
|
||||
services: serviceHits,
|
||||
software: softwareHits,
|
||||
events: eventHits,
|
||||
finance: financeHits,
|
||||
};
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type Service } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { ServiceActionState } from "./service-types";
|
||||
import { serviceSchema } from "@/lib/validation/services";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) {
|
||||
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
}
|
||||
return "Bağlantı hatası. Tekrar deneyin.";
|
||||
}
|
||||
|
||||
function pickFormFields(formData: FormData) {
|
||||
return {
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
unitPrice: String(formData.get("unitPrice") ?? "0"),
|
||||
currency: (formData.get("currency") as "TRY" | "USD" | "EUR" | null) ?? "TRY",
|
||||
recurring: formData.get("recurring") ?? false,
|
||||
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
|
||||
"onetime",
|
||||
assigneeIds: formData.getAll("assigneeIds").map(String).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
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")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function createServiceAction(
|
||||
_prev: ServiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<ServiceActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = serviceSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.services,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "service",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/services");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateServiceAction(
|
||||
_prev: ServiceActionState,
|
||||
formData: FormData,
|
||||
): Promise<ServiceActionState> {
|
||||
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 = serviceSchema.safeParse(pickFormFields(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.services,
|
||||
id,
|
||||
)) as unknown as Service;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "service",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/services");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteServiceAction(formData: FormData): Promise<ServiceActionState> {
|
||||
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.services,
|
||||
id,
|
||||
)) as unknown as Service;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.services, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "service",
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/services");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type Service } from "./schema";
|
||||
|
||||
export async function listServices(tenantId: string): Promise<Service[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Service[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServicesByCustomer(
|
||||
tenantId: string,
|
||||
customerId: string,
|
||||
): Promise<Service[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.services,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("customerId", customerId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Service[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type ServiceActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialServiceState: ServiceActionState = { ok: false };
|
||||
@@ -1,388 +0,0 @@
|
||||
"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 {
|
||||
isPlanLimitError,
|
||||
planLimitMessage,
|
||||
requirePlanCapacity,
|
||||
} from "./plan-limits";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CustomerSoftware,
|
||||
type Software,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { SoftwareActionState } from "./software-types";
|
||||
import { customerSoftwareSchema, softwareSchema } from "@/lib/validation/software";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) {
|
||||
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
}
|
||||
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")),
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------- Software (catalog) --------------------
|
||||
|
||||
function pickSoftwareFields(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
version: String(formData.get("version") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
defaultFee: String(formData.get("defaultFee") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSoftwareAction(
|
||||
_prev: SoftwareActionState,
|
||||
formData: FormData,
|
||||
): Promise<SoftwareActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = softwareSchema.safeParse(pickSoftwareFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePlanCapacity(ctx, "software");
|
||||
} catch (e) {
|
||||
if (isPlanLimitError(e)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: planLimitMessage(e.resource, e.limit),
|
||||
code: "PLAN_LIMIT_EXCEEDED",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.software,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...parsed.data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "software",
|
||||
entityId: row.$id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateSoftwareAction(
|
||||
_prev: SoftwareActionState,
|
||||
formData: FormData,
|
||||
): Promise<SoftwareActionState> {
|
||||
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 = softwareSchema.safeParse(pickSoftwareFields(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.software,
|
||||
id,
|
||||
)) as unknown as Software;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.software, id, parsed.data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "software",
|
||||
entityId: id,
|
||||
changes: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteSoftwareAction(formData: FormData): Promise<SoftwareActionState> {
|
||||
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.software,
|
||||
id,
|
||||
)) as unknown as Software;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
// Detach from all customer_software rows first
|
||||
const assignments = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSoftware,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("softwareId", id),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
for (const row of assignments.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, row.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.software, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "software",
|
||||
entityId: id,
|
||||
changes: { name: existing.name, detachedAssignments: assignments.rows.length },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// -------------------- customer_software (assignments) --------------------
|
||||
|
||||
function pickAssignmentFields(formData: FormData) {
|
||||
return {
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
softwareId: String(formData.get("softwareId") ?? ""),
|
||||
startDate: String(formData.get("startDate") ?? ""),
|
||||
endDate: String(formData.get("endDate") ?? ""),
|
||||
fee: String(formData.get("fee") ?? ""),
|
||||
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
|
||||
"monthly",
|
||||
notes: String(formData.get("notes") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoDate(v?: string): string | undefined {
|
||||
if (!v) return undefined;
|
||||
// input type=date sends YYYY-MM-DD; Appwrite expects ISO with timezone
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function createAssignmentAction(
|
||||
_prev: SoftwareActionState,
|
||||
formData: FormData,
|
||||
): Promise<SoftwareActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = {
|
||||
...parsed.data,
|
||||
startDate: toIsoDate(parsed.data.startDate),
|
||||
endDate: toIsoDate(parsed.data.endDate),
|
||||
};
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.customerSoftware,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
...data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "customer_software",
|
||||
entityId: row.$id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateAssignmentAction(
|
||||
_prev: SoftwareActionState,
|
||||
formData: FormData,
|
||||
): Promise<SoftwareActionState> {
|
||||
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 = customerSoftwareSchema.safeParse(pickAssignmentFields(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.customerSoftware,
|
||||
id,
|
||||
)) as unknown as CustomerSoftware;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = {
|
||||
...parsed.data,
|
||||
startDate: toIsoDate(parsed.data.startDate),
|
||||
endDate: toIsoDate(parsed.data.endDate),
|
||||
};
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.customerSoftware, id, data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "customer_software",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteAssignmentAction(formData: FormData): Promise<SoftwareActionState> {
|
||||
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.customerSoftware,
|
||||
id,
|
||||
)) as unknown as CustomerSoftware;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "customer_software",
|
||||
entityId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/software");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CustomerSoftware,
|
||||
type Software,
|
||||
} from "./schema";
|
||||
|
||||
export async function listSoftware(tenantId: string): Promise<Software[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.software,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("name"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Software[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssignments(tenantId: string): Promise<CustomerSoftware[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.customerSoftware,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(1000),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as CustomerSoftware[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type SoftwareActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
code?: "PLAN_LIMIT_EXCEEDED";
|
||||
};
|
||||
|
||||
export const initialSoftwareState: SoftwareActionState = { ok: false };
|
||||
@@ -1,323 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { persistCardFromMockCheckout } from "./saved-card-actions";
|
||||
import { getDefaultCard } from "./saved-card-queries";
|
||||
import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import { PLAN_CATALOG } from "./subscription-types";
|
||||
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
|
||||
import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
|
||||
|
||||
const PRO_VALIDITY_DAYS = 30;
|
||||
|
||||
function teamRowPermissions(tenantId: string) {
|
||||
return [
|
||||
Permission.read(Role.team(tenantId, "owner")),
|
||||
Permission.read(Role.team(tenantId, "admin")),
|
||||
Permission.update(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
];
|
||||
}
|
||||
|
||||
function generateOrderId(): string {
|
||||
const t = Date.now().toString(36);
|
||||
const r = Math.random().toString(36).slice(2, 10);
|
||||
return `ord_${t}_${r}`;
|
||||
}
|
||||
|
||||
export async function startMockCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const orderId = generateOrderId();
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "mock",
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "mock" },
|
||||
});
|
||||
|
||||
redirect(`/settings/billing/checkout/${orderId}`);
|
||||
}
|
||||
|
||||
async function findPendingPaymentByOrderId(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
): Promise<SubscriptionPayment | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.subscriptionPayments,
|
||||
queries: [Query.equal("orderId", orderId), Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
});
|
||||
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
|
||||
}
|
||||
|
||||
export async function confirmMockPaymentAction(formData: FormData): Promise<void> {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
if (!orderId) throw new Error("orderId eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||
if (!payment) throw new Error("Ödeme bulunamadı.");
|
||||
if (payment.status === "success") {
|
||||
redirect(`/settings/billing?upgraded=1`);
|
||||
}
|
||||
if (payment.provider !== "mock") {
|
||||
throw new Error("Bu ödeme mock olarak onaylanamaz.");
|
||||
}
|
||||
|
||||
const saveCard = String(formData.get("saveCard") ?? "") === "true";
|
||||
const useSavedCardId = String(formData.get("savedCardId") ?? "");
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||
status: "success",
|
||||
processedAt: now.toISOString(),
|
||||
providerPayload: JSON.stringify({
|
||||
mock: true,
|
||||
confirmedBy: ctx.user.id,
|
||||
usedSavedCardId: useSavedCardId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ctx.settings) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||
plan: payment.plan,
|
||||
planStartedAt: now.toISOString(),
|
||||
planExpiresAt: expires.toISOString(),
|
||||
lastPaymentId: payment.$id,
|
||||
});
|
||||
}
|
||||
|
||||
if (saveCard && !useSavedCardId) {
|
||||
const last4 = String(formData.get("cardLast4") ?? "").replace(/\D/g, "").slice(-4);
|
||||
const month = parseInt(String(formData.get("cardExpiryMonth") ?? "0"), 10);
|
||||
const year = parseInt(String(formData.get("cardExpiryYear") ?? "0"), 10);
|
||||
const brand = String(formData.get("cardBrand") ?? "unknown");
|
||||
const holder = String(formData.get("cardHolder") ?? "").trim();
|
||||
|
||||
if (last4.length === 4 && month > 0 && year >= 2026) {
|
||||
const existingDefault = await getDefaultCard(ctx.tenantId);
|
||||
try {
|
||||
await persistCardFromMockCheckout({
|
||||
brand,
|
||||
last4,
|
||||
expiryMonth: month,
|
||||
expiryYear: year,
|
||||
holderName: holder,
|
||||
makeDefault: !existingDefault,
|
||||
});
|
||||
} catch {
|
||||
// best-effort — payment already succeeded, don't fail upgrade if card save errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "subscription_payment",
|
||||
entityId: payment.$id,
|
||||
changes: { status: "success", plan: payment.plan, expires: expires.toISOString() },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?upgraded=1`);
|
||||
}
|
||||
|
||||
export async function cancelMockPaymentAction(formData: FormData): Promise<void> {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
if (!orderId) throw new Error("orderId eksik.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
|
||||
if (!payment) {
|
||||
redirect(`/settings/billing`);
|
||||
}
|
||||
if (payment && payment.status === "pending") {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
|
||||
status: "failed",
|
||||
processedAt: new Date().toISOString(),
|
||||
providerPayload: JSON.stringify({ mock: true, cancelledBy: ctx.user.id }),
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "subscription_payment",
|
||||
entityId: payment.$id,
|
||||
changes: { status: "failed" },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?cancelled=1`);
|
||||
}
|
||||
|
||||
export async function downgradeToFreeAction(): Promise<void> {
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
if (!ctx.settings) throw new Error("Ayar yok.");
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
|
||||
plan: "free",
|
||||
planExpiresAt: null,
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "tenant_settings",
|
||||
entityId: ctx.settings.$id,
|
||||
changes: { plan: "free" },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/billing");
|
||||
redirect(`/settings/billing?downgraded=1`);
|
||||
}
|
||||
|
||||
export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const storeUrl = getShopierPlanUrl(plan);
|
||||
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const orderId = generateOrderId();
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "shopier",
|
||||
// Webhook'ta tenant eşleştirmek için alıcı emailini sakla
|
||||
providerPayload: JSON.stringify({ userEmail: ctx.user.email }),
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "shopier" },
|
||||
});
|
||||
|
||||
// Kullanıcıyı doğrudan Shopier mağaza ürün sayfasına yönlendir
|
||||
redirect(storeUrl);
|
||||
}
|
||||
|
||||
export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
|
||||
const plan = String(formData.get("plan") ?? "") as TenantPlan;
|
||||
if (plan !== "pro") throw new Error("Geçersiz plan.");
|
||||
|
||||
const ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner"]);
|
||||
|
||||
const catalog = PLAN_CATALOG[plan];
|
||||
const orderId = generateOrderId();
|
||||
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.subscriptionPayments,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
orderId,
|
||||
plan,
|
||||
amount: catalog.price,
|
||||
currency: catalog.currency,
|
||||
status: "pending",
|
||||
provider: "polar",
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "subscription_payment",
|
||||
entityId: orderId,
|
||||
changes: { plan, amount: catalog.price, provider: "polar" },
|
||||
});
|
||||
|
||||
const checkout = await createPolarCheckout({
|
||||
orderId,
|
||||
tenantId: ctx.tenantId,
|
||||
userEmail: ctx.user.email,
|
||||
successUrl: `${appUrl}/settings/billing?upgraded=1`,
|
||||
});
|
||||
|
||||
redirect(checkout.url);
|
||||
}
|
||||
|
||||
// Unified entry point — PAYMENT_PROVIDER env ile yönlendirir.
|
||||
export async function startCheckoutAction(formData: FormData): Promise<void> {
|
||||
if (isPolarEnabled()) return startPolarCheckoutAction(formData);
|
||||
if (isShopierEnabled()) return startShopierCheckoutAction(formData);
|
||||
return startMockCheckoutAction(formData);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type SubscriptionPayment } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
|
||||
export async function getPaymentByOrderId(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
): Promise<SubscriptionPayment | null> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.subscriptionPayments,
|
||||
queries: [
|
||||
Query.equal("orderId", orderId),
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.limit(1),
|
||||
],
|
||||
});
|
||||
return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
|
||||
}
|
||||
|
||||
export async function listPaymentsForTenant(
|
||||
tenantId: string,
|
||||
limit = 20,
|
||||
): Promise<SubscriptionPayment[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.subscriptionPayments,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(limit),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as SubscriptionPayment[];
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TenantPlan } from "./schema";
|
||||
|
||||
export type PlanCatalogEntry = {
|
||||
id: TenantPlan;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export const PLAN_CATALOG: Record<TenantPlan, PlanCatalogEntry> = {
|
||||
free: {
|
||||
id: "free",
|
||||
name: "Ücretsiz",
|
||||
price: 0,
|
||||
currency: "TRY",
|
||||
description: "Tek kullanıcı, denemek için.",
|
||||
features: [
|
||||
"50 müşteri",
|
||||
"100 finans kaydı",
|
||||
"5 yazılım",
|
||||
"Tek kullanıcı",
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: 299,
|
||||
currency: "TRY",
|
||||
description: "Sınırsız büyüyen ekipler için.",
|
||||
features: [
|
||||
"Sınırsız müşteri",
|
||||
"Sınırsız finans kaydı",
|
||||
"Sınırsız yazılım",
|
||||
"Sınırsız ekip üyesi",
|
||||
"Audit log + öncelikli destek",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { DATABASE_ID, TABLES, type Task, type TaskStatus } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
import type { TaskActionState } from "./task-types";
|
||||
import { taskSchema } from "@/lib/validation/tasks";
|
||||
|
||||
function appwriteError(e: unknown): string {
|
||||
if (e instanceof AppwriteException) {
|
||||
return e.message || "Beklenmeyen bir hata oluştu.";
|
||||
}
|
||||
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 pickFormFields(formData: FormData) {
|
||||
return {
|
||||
title: String(formData.get("title") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
status: (formData.get("status") as TaskStatus | null) ?? "todo",
|
||||
priority: (formData.get("priority") as "low" | "medium" | "high" | "urgent" | null) ??
|
||||
"medium",
|
||||
dueDate: String(formData.get("dueDate") ?? ""),
|
||||
assigneeId: String(formData.get("assigneeId") ?? ""),
|
||||
customerId: String(formData.get("customerId") ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoDate(v?: string): string | undefined {
|
||||
if (!v) return undefined;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function createTaskAction(
|
||||
_prev: TaskActionState,
|
||||
formData: FormData,
|
||||
): Promise<TaskActionState> {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
const parsed = taskSchema.safeParse(pickFormFields(formData));
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.tasks,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.user.id,
|
||||
order: Date.now(),
|
||||
...data,
|
||||
},
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "create",
|
||||
entityType: "task",
|
||||
entityId: row.$id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/tasks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateTaskAction(
|
||||
_prev: TaskActionState,
|
||||
formData: FormData,
|
||||
): Promise<TaskActionState> {
|
||||
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 = taskSchema.safeParse(pickFormFields(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.tasks,
|
||||
id,
|
||||
)) as unknown as Task;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
const data = { ...parsed.data, dueDate: toIsoDate(parsed.data.dueDate) };
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, data);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/tasks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteTaskAction(formData: FormData): Promise<TaskActionState> {
|
||||
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.tasks,
|
||||
id,
|
||||
)) as unknown as Task;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.tasks, id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "delete",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { title: existing.title },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/tasks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by Kanban drag-drop. Updates status and order in one server call.
|
||||
*/
|
||||
export async function moveTaskAction(
|
||||
id: string,
|
||||
status: TaskStatus,
|
||||
order: number,
|
||||
): Promise<TaskActionState> {
|
||||
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.tasks,
|
||||
id,
|
||||
)) as unknown as Task;
|
||||
|
||||
if (existing.tenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Erişim engellendi." };
|
||||
}
|
||||
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.tasks, id, { status, order });
|
||||
|
||||
if (existing.status !== status) {
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { from: existing.status, to: status },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/tasks");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import { DATABASE_ID, TABLES, type Task } from "./schema";
|
||||
|
||||
export async function listTasks(tenantId: string): Promise<Task[]> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tasks,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderAsc("order"),
|
||||
Query.limit(1000),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as Task[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type TaskActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialTaskState: TaskActionState = { ok: false };
|
||||
@@ -5,11 +5,6 @@ import { revalidatePath } from "next/cache";
|
||||
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import {
|
||||
isPlanLimitError,
|
||||
planLimitMessage,
|
||||
requirePlanCapacity,
|
||||
} from "./plan-limits";
|
||||
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
|
||||
import { createAdminClient, createSessionClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
@@ -71,19 +66,6 @@ export async function inviteMemberAction(
|
||||
return { ok: false, error: "Kendinizi davet edemezsiniz." };
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePlanCapacity(ctx, "members");
|
||||
} catch (e) {
|
||||
if (isPlanLimitError(e)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: planLimitMessage(e.resource, e.limit),
|
||||
code: "PLAN_LIMIT_EXCEEDED",
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const admin = createAdminClient();
|
||||
|
||||
// 1. Kullanıcı zaten Appwrite'ta var mı?
|
||||
|
||||
Reference in New Issue
Block a user