feat(finance): personal vs company scope for banking + finance entries

User-level data privacy on finance entities. Bireysel = sadece sahibi
görür/düzenler/siler, Şirket = takım görür (mevcut davranış).

Schema additions (4 tables, all enum company|personal default 'company'):
- bank_accounts.scope
- bank_loans.scope
- credit_cards.scope
- finance_entries.scope
+ tenantId_scope index on each.

Inherited fields (no own scope, parent's used):
- loan_installments → from bank_loan
- credit_card_statements → from credit_card

Permissions (lib/appwrite/scope-permissions.ts):
- scopedRowPermissions(tenantId, createdBy, scope):
  * company: Permission.read/update Role.team(tenantId), delete Role.team
    owner|admin (current behavior)
  * personal: read/update/delete Role.user(createdBy) only
- canAccessRow(row, userId): true if scope=company OR createdBy=userId.
  Used as a defense-in-depth check inside actions because we use the
  admin SDK (which bypasses row-level perms).

Action updates:
- bank-account-actions, loan-actions, credit-card-actions, finance-actions:
  pickFormFields includes scope; create uses scopedRowPermissions; update
  re-applies perms when scope changes; update/delete check canAccessRow
  on top of the existing tenantId check.
- loan installment payment & credit card statement payment auto-create
  finance entries that inherit the parent's scope, so a personal loan
  installment doesn't create a company income/expense.

Query updates (all accept optional currentUserId):
- listBankAccounts, listLoans, listCreditCards, listFinanceEntries:
  pull all tenant rows then in-JS filter via canAccessRow.
- getBankAccountBalances respects visible accounts only.
- listAllInstallments / listStatements: filter to only those whose
  parent loan/card is visible.

UI:
- New shared component components/finance/scope-toggle.tsx with
  ScopeToggle (form input) and ScopeBadge (visual marker).
- Bank, loan, card form sheets and the finance form sheet now include
  a Şirket/Bireysel toggle at the top.
- Bank account cards display ScopeBadge for personal entries.
- Page-level queries everywhere now pass ctx.user.id so each user only
  sees their personal rows + the team's company rows.

Reports & Dashboard:
- getDashboardData filters finance entries to scope=company only — so
  team-level metrics never include any user's personal data.
- getFinancialReport (CFO view): bank accounts, loans, cards, finance
  entries, installments and statements all filtered to company scope.
  Personal entities never appear in reports anywhere.

Invoice → finance entry sync explicitly tags scope=company since invoices
are inherently company-scope.
This commit is contained in:
kovakmedya
2026-04-30 08:36:01 +03:00
parent 2549ce097c
commit 1f79abe404
30 changed files with 386 additions and 116 deletions
@@ -21,6 +21,7 @@ import {
updateBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountRow } from "./types";
@@ -60,6 +61,8 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) {
{isEdit && account && <input type="hidden" name="id" value={account.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={(account as { scope?: "company" | "personal" } | null)?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
@@ -36,6 +36,7 @@ import {
deleteBankAccountAction,
} from "@/lib/appwrite/bank-account-actions";
import { formatTRY } from "@/lib/format";
import { ScopeBadge } from "@/components/finance/scope-toggle";
import { cn } from "@/lib/utils";
import { BankFormSheet } from "./bank-form-sheet";
@@ -229,6 +230,7 @@ function AccountCard({
Arşivli
</Badge>
)}
<ScopeBadge scope={account.scope} />
</div>
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
{account.iban && (
@@ -7,4 +7,5 @@ export type BankAccountRow = {
notes: string;
archived: boolean;
balance: number;
scope: "company" | "personal";
};
+3 -2
View File
@@ -21,8 +21,8 @@ export default async function BanksPage() {
}
const [accounts, balances] = await Promise.all([
listBankAccounts(ctx.tenantId),
getBankAccountBalances(ctx.tenantId),
listBankAccounts(ctx.tenantId, ctx.user.id),
getBankAccountBalances(ctx.tenantId, ctx.user.id),
]);
return (
@@ -45,6 +45,7 @@ export default async function BanksPage() {
notes: a.notes ?? "",
archived: Boolean(a.archived),
balance: balances.get(a.$id) ?? a.openingBalance ?? 0,
scope: (a.scope ?? "company") as "company" | "personal",
}))}
/>
</div>
@@ -28,6 +28,7 @@ import {
updateCreditCardAction,
} from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, CreditCardRow } from "./types";
@@ -78,6 +79,8 @@ export function CardFormSheet({
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle defaultValue={card?.scope ?? "company"} />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
@@ -11,6 +11,7 @@ export type CreditCardRow = {
bankAccountLabel: string;
archived: boolean;
notes: string;
scope: "company" | "personal";
};
export type StatementRow = {
+4 -3
View File
@@ -22,9 +22,9 @@ export default async function CardsPage() {
}
const [cards, statements, bankAccounts] = await Promise.all([
listCreditCards(ctx.tenantId),
listStatements(ctx.tenantId),
listBankAccounts(ctx.tenantId),
listCreditCards(ctx.tenantId, ctx.user.id),
listStatements(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
@@ -55,6 +55,7 @@ export default async function CardsPage() {
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
archived: Boolean(c.archived),
notes: c.notes ?? "",
scope: (c.scope ?? "company") as "company" | "personal",
}))}
statements={statements.map((s) => ({
id: s.$id,
@@ -28,6 +28,7 @@ import {
updateFinanceEntryAction,
} from "@/lib/appwrite/finance-actions";
import { initialFinanceState } from "@/lib/appwrite/finance-types";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption, Customer, FinanceRow, FinanceType } from "./types";
@@ -95,6 +96,10 @@ export function FinanceFormSheet({
{isEdit && entry && <input type="hidden" name="id" value={entry.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle
defaultValue={(entry as { scope?: "company" | "personal" } | null | undefined)?.scope ?? "company"}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="type">Tür *</Label>
@@ -26,6 +26,7 @@ import { Textarea } from "@/components/ui/textarea";
import { createLoanAction } from "@/lib/appwrite/loan-actions";
import { initialLoanState } from "@/lib/appwrite/loan-types";
import { formatTRY } from "@/lib/format";
import { ScopeToggle } from "@/components/finance/scope-toggle";
import type { BankAccountOption } from "./types";
@@ -85,6 +86,8 @@ export function LoanFormSheet({
className="flex flex-1 flex-col"
>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<ScopeToggle />
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
@@ -17,6 +17,7 @@ export type LoanRow = {
paidAmount: number;
remainingCount: number;
nextDue: string | null;
scope: "company" | "personal";
};
export type InstallmentRow = {
+4 -3
View File
@@ -19,9 +19,9 @@ export default async function LoansPage() {
}
const [loans, installments, bankAccounts] = await Promise.all([
listLoans(ctx.tenantId),
listAllInstallments(ctx.tenantId),
listBankAccounts(ctx.tenantId),
listLoans(ctx.tenantId, ctx.user.id),
listAllInstallments(ctx.tenantId, ctx.user.id),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const bankMap = new Map(
@@ -90,6 +90,7 @@ export default async function LoansPage() {
paidAmount: m.paidAmount,
remainingCount: m.remainingCount,
nextDue: m.nextDue,
scope: (l.scope ?? "company") as "company" | "personal",
};
})}
installments={installments.map((i) => ({
+2 -2
View File
@@ -20,9 +20,9 @@ export default async function FinancePage() {
}
const [entries, customers, bankAccounts] = await Promise.all([
listFinanceEntries(ctx.tenantId),
listFinanceEntries(ctx.tenantId, ctx.user.id),
listCustomers(ctx.tenantId),
listBankAccounts(ctx.tenantId),
listBankAccounts(ctx.tenantId, ctx.user.id),
]);
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { Building2, User } from "lucide-react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Scope = "company" | "personal";
export function ScopeToggle({
name = "scope",
defaultValue = "company",
label = "Kapsam",
description,
}: {
name?: string;
defaultValue?: Scope;
label?: string;
description?: string;
}) {
const [value, setValue] = useState<Scope>(defaultValue);
return (
<div className="grid gap-2">
<Label>{label}</Label>
<input type="hidden" name={name} value={value} />
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setValue("company")}
className={cn(
"border-input flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors",
value === "company"
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="size-4" />
Şirket
</div>
<p className="text-muted-foreground text-xs">
Ekipteki herkes görür ve düzenleyebilir.
</p>
</button>
<button
type="button"
onClick={() => setValue("personal")}
className={cn(
"border-input flex flex-col items-start gap-1 rounded-md border p-3 text-left transition-colors",
value === "personal"
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
>
<div className="flex items-center gap-2 text-sm font-medium">
<User className="size-4" />
Bireysel
</div>
<p className="text-muted-foreground text-xs">
Yalnızca siz görürsünüz, ekibe yansımaz.
</p>
</button>
</div>
{description && <p className="text-muted-foreground text-xs">{description}</p>}
</div>
);
}
export function ScopeBadge({ scope }: { scope?: Scope }) {
if (scope === "personal") {
return (
<span className="bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30 inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-medium">
<User className="size-2.5" />
Bireysel
</span>
);
}
return null; // Company is default — no badge needed
}
+14 -15
View File
@@ -1,11 +1,12 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
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";
@@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
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 {
bankName: String(formData.get("bankName") ?? "").trim(),
@@ -41,6 +33,7 @@ function pickFormFields(formData: FormData) {
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",
};
}
@@ -71,7 +64,7 @@ export async function createBankAccountAction(
createdBy: ctx.user.id,
...parsed.data,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
);
await logAudit({
@@ -116,11 +109,17 @@ export async function updateBankAccountAction(
TABLES.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId) {
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);
await tablesDB.updateRow(
DATABASE_ID,
TABLES.bankAccounts,
id,
parsed.data,
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
@@ -158,7 +157,7 @@ export async function archiveBankAccountAction(
TABLES.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
@@ -203,7 +202,7 @@ export async function deleteBankAccountAction(
TABLES.bankAccounts,
id,
)) as unknown as BankAccount;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
+20 -14
View File
@@ -2,6 +2,7 @@ import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
@@ -10,7 +11,15 @@ import {
type FinanceEntry,
} from "./schema";
export async function listBankAccounts(tenantId: string): Promise<BankAccount[]> {
/**
* 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({
@@ -22,30 +31,28 @@ export async function listBankAccounts(tenantId: string): Promise<BankAccount[]>
Query.limit(200),
],
});
return result.rows as unknown as BankAccount[];
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 account: openingBalance + Σ(income/receivable) Σ(expense/debt).
* 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 { tablesDB } = createAdminClient();
const accounts = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankAccounts,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
});
for (const a of accounts.rows as unknown as BankAccount[]) {
balances.set(a.$id, a.openingBalance ?? 0);
}
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,
@@ -56,12 +63,11 @@ export async function getBankAccountBalances(
],
});
for (const e of entries.rows as unknown as FinanceEntry[]) {
if (!e.bankAccountId) continue;
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);
// debt/receivable don't affect cash balance
}
} catch {
/* ignore */
+33 -18
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { AppwriteException, ID, Query } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
@@ -11,6 +11,7 @@ import {
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";
@@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
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`;
@@ -58,6 +50,7 @@ function pickCardFields(formData: FormData) {
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",
};
}
@@ -88,7 +81,7 @@ export async function createCreditCardAction(
createdBy: ctx.user.id,
...parsed.data,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
);
await logAudit({
@@ -133,11 +126,17 @@ export async function updateCreditCardAction(
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
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);
await tablesDB.updateRow(
DATABASE_ID,
TABLES.creditCards,
id,
parsed.data,
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
@@ -175,7 +174,7 @@ export async function archiveCreditCardAction(
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
const newArchivedState = !existing.archived;
@@ -219,7 +218,7 @@ export async function deleteCreditCardAction(
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
@@ -310,11 +309,13 @@ export async function createStatementAction(
TABLES.creditCards,
parsed.data.cardId,
)) as unknown as CreditCard;
if (card.tenantId !== ctx.tenantId) {
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,
@@ -333,7 +334,7 @@ export async function createStatementAction(
status,
notes: parsed.data.notes,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
);
await logAudit({
@@ -393,6 +394,10 @@ export async function payStatementAction(formData: FormData): Promise<CreditCard
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,
@@ -406,8 +411,9 @@ export async function payStatementAction(formData: FormData): Promise<CreditCard
date: new Date().toISOString(),
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
bankAccountId: card.bankAccountId,
scope: cardScope,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
);
const newPaid = (existing.paidAmount ?? 0) + payAmount;
@@ -459,6 +465,15 @@ export async function deleteStatementAction(
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(
+30 -5
View File
@@ -2,6 +2,7 @@ import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
@@ -10,7 +11,10 @@ import {
type CreditCardStatement,
} from "./schema";
export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
export async function listCreditCards(
tenantId: string,
currentUserId?: string,
): Promise<CreditCard[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
@@ -22,18 +26,25 @@ export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
Query.limit(200),
],
});
return result.rows as unknown as CreditCard[];
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 result = await tablesDB.listRows({
const [allStmt, allCards] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [
@@ -41,8 +52,22 @@ export async function listStatements(
Query.orderDesc("statementDate"),
Query.limit(500),
],
});
return result.rows as unknown as CreditCardStatement[];
}),
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 [];
}
+5 -1
View File
@@ -97,7 +97,11 @@ export async function getDashboardData(
const customerList = customers.rows as unknown as Customer[];
const invoiceList = invoices.rows as unknown as Invoice[];
const entryList = financeEntries.rows as unknown as FinanceEntry[];
// 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]));
+13 -14
View File
@@ -1,11 +1,12 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { AppwriteException, ID } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
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";
@@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
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 {
type: formData.get("type") as "income" | "expense" | "debt" | "receivable",
@@ -50,6 +42,7 @@ function pickFormFields(formData: FormData) {
| "other"
| null,
bankAccountId: String(formData.get("bankAccountId") ?? ""),
scope: (formData.get("scope") as "company" | "personal" | null) ?? "company",
};
}
@@ -87,7 +80,7 @@ export async function createFinanceEntryAction(
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
);
await logAudit({
@@ -132,12 +125,18 @@ export async function updateFinanceEntryAction(
TABLES.financeEntries,
id,
)) as unknown as FinanceEntry;
if (existing.tenantId !== ctx.tenantId) {
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);
await tablesDB.updateRow(
DATABASE_ID,
TABLES.financeEntries,
id,
data,
scopedRowPermissions(ctx.tenantId, existing.createdBy, parsed.data.scope),
);
await logAudit({
tenantId: ctx.tenantId,
@@ -175,7 +174,7 @@ export async function deleteFinanceEntryAction(
TABLES.financeEntries,
id,
)) as unknown as FinanceEntry;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
+8 -2
View File
@@ -2,10 +2,14 @@ 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): Promise<FinanceEntry[]> {
export async function listFinanceEntries(
tenantId: string,
currentUserId?: string,
): Promise<FinanceEntry[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
@@ -17,7 +21,9 @@ export async function listFinanceEntries(tenantId: string): Promise<FinanceEntry
Query.limit(1000),
],
});
return result.rows as unknown as FinanceEntry[];
const rows = result.rows as unknown as FinanceEntry[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
+17 -6
View File
@@ -183,14 +183,25 @@ export async function getFinancialReport(
.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[];
const bankList = bankAccounts.rows as unknown as BankAccount[];
const loanList = loans.rows as unknown as BankLoan[];
const installmentList = installments.rows as unknown as LoanInstallment[];
const cardList = cards.rows as unknown as CreditCard[];
const statementList = statements.rows as unknown as CreditCardStatement[];
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(
+1
View File
@@ -111,6 +111,7 @@ async function syncPaymentEntry(
description: `Fatura ${invoice.number} tahsilatı`,
customerId: invoice.customerId,
invoiceId: invoice.$id,
scope: "company",
},
[
Permission.read(Role.team(tenantId)),
+28 -15
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { AppwriteException, ID, Query } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
@@ -11,6 +11,7 @@ import {
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";
@@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
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 pickLoanFields(formData: FormData) {
return {
bankAccountId: String(formData.get("bankAccountId") ?? ""),
@@ -58,6 +50,7 @@ function pickLoanFields(formData: FormData) {
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",
};
}
@@ -138,6 +131,9 @@ export async function createLoanAction(
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,
@@ -158,8 +154,9 @@ export async function createLoanAction(
paymentDay: parsed.data.paymentDay,
status: "active",
notes: parsed.data.notes,
scope: parsed.data.scope,
},
teamRowPermissions(ctx.tenantId),
rowPerms,
);
loanId = loan.$id;
@@ -181,7 +178,7 @@ export async function createLoanAction(
interestPart: slice.interestPart,
paid: false,
},
teamRowPermissions(ctx.tenantId),
rowPerms,
);
}
@@ -239,7 +236,7 @@ export async function deleteLoanAction(formData: FormData): Promise<LoanActionSt
TABLES.bankLoans,
id,
)) as unknown as BankLoan;
if (existing.tenantId !== ctx.tenantId) {
if (existing.tenantId !== ctx.tenantId || !canAccessRow(existing, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
@@ -305,8 +302,13 @@ export async function payInstallmentAction(formData: FormData): Promise<LoanActi
TABLES.bankLoans,
existing.loanId,
)) as unknown as BankLoan;
if (!canAccessRow(loan, ctx.user.id)) {
return { ok: false, error: "Erişim engellendi." };
}
// Create finance entry: expense, linked
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,
@@ -319,8 +321,9 @@ export async function payInstallmentAction(formData: FormData): Promise<LoanActi
date: new Date().toISOString(),
description: `${loan.bankName}${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
bankAccountId: loan.bankAccountId,
scope: loanScope,
},
teamRowPermissions(ctx.tenantId),
scopedRowPermissions(ctx.tenantId, ctx.user.id, loanScope),
);
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
@@ -388,6 +391,16 @@ export async function unpayInstallmentAction(
}
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(
+32 -6
View File
@@ -2,6 +2,7 @@ import "server-only";
import { Query } from "node-appwrite";
import { canAccessRow } from "./scope-permissions";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
@@ -10,7 +11,10 @@ import {
type LoanInstallment,
} from "./schema";
export async function listLoans(tenantId: string): Promise<BankLoan[]> {
export async function listLoans(
tenantId: string,
currentUserId?: string,
): Promise<BankLoan[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
@@ -22,7 +26,9 @@ export async function listLoans(tenantId: string): Promise<BankLoan[]> {
Query.limit(200),
],
});
return result.rows as unknown as BankLoan[];
const rows = result.rows as unknown as BankLoan[];
if (!currentUserId) return rows;
return rows.filter((r) => canAccessRow(r, currentUserId));
} catch {
return [];
}
@@ -50,15 +56,35 @@ export async function listInstallmentsForLoan(
}
}
export async function listAllInstallments(tenantId: string): Promise<LoanInstallment[]> {
/**
* 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 result = await tablesDB.listRows({
const [allInst, allLoans] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.loanInstallments,
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
});
return result.rows as unknown as LoanInstallment[];
}),
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 [];
}
+6
View File
@@ -139,6 +139,7 @@ export interface FinanceEntry extends Row {
invoiceId?: string;
paymentMethod?: PaymentMethod;
bankAccountId?: string;
scope?: "company" | "personal";
}
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
@@ -181,6 +182,8 @@ export interface AuditLog extends Row {
userAgent?: string;
}
export type FinanceScope = "company" | "personal";
export interface BankAccount extends Row {
tenantId: string;
createdBy: string;
@@ -190,6 +193,7 @@ export interface BankAccount extends Row {
openingBalance?: number;
notes?: string;
archived?: boolean;
scope?: FinanceScope;
}
export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
@@ -210,6 +214,7 @@ export interface BankLoan extends Row {
paymentDay?: number;
status?: LoanStatus;
notes?: string;
scope?: FinanceScope;
}
export interface LoanInstallment extends Row {
@@ -238,6 +243,7 @@ export interface CreditCard extends Row {
bankAccountId?: string;
archived?: boolean;
notes?: string;
scope?: FinanceScope;
}
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
+53
View File
@@ -0,0 +1,53 @@
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
View File
@@ -23,6 +23,7 @@ export const bankAccountSchema = z.object({
.max(500)
.optional()
.transform((v) => (v ? v : undefined)),
scope: z.enum(["company", "personal"]).optional().default("company"),
});
export type BankAccountInput = z.infer<typeof bankAccountSchema>;
+1
View File
@@ -30,6 +30,7 @@ export const bankLoanSchema = z.object({
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1;
}),
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
scope: z.enum(["company", "personal"]).optional().default("company"),
});
export type BankLoanInput = z.infer<typeof bankLoanSchema>;
+1
View File
@@ -43,6 +43,7 @@ export const creditCardSchema = z.object({
}),
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
scope: z.enum(["company", "personal"]).optional().default("company"),
});
export type CreditCardInput = z.infer<typeof creditCardSchema>;
+1
View File
@@ -15,6 +15,7 @@ export const financeEntrySchema = z.object({
.optional()
.transform((v) => v || undefined),
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
scope: z.enum(["company", "personal"]).optional().default("company"),
});
export type FinanceEntryInput = z.infer<typeof financeEntrySchema>;