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:
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,27 +26,48 @@ 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({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.creditCardStatements,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.orderDesc("statementDate"),
|
||||
Query.limit(500),
|
||||
],
|
||||
});
|
||||
return result.rows as unknown as CreditCardStatement[];
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.loanInstallments,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
||||
});
|
||||
return result.rows as unknown as LoanInstallment[];
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user