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,
|
updateBankAccountAction,
|
||||||
} from "@/lib/appwrite/bank-account-actions";
|
} from "@/lib/appwrite/bank-account-actions";
|
||||||
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
|
import { initialBankAccountState } from "@/lib/appwrite/bank-account-types";
|
||||||
|
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||||
|
|
||||||
import type { BankAccountRow } from "./types";
|
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} />}
|
{isEdit && account && <input type="hidden" name="id" value={account.id} />}
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
<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-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="bankName">Banka *</Label>
|
<Label htmlFor="bankName">Banka *</Label>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
deleteBankAccountAction,
|
deleteBankAccountAction,
|
||||||
} from "@/lib/appwrite/bank-account-actions";
|
} from "@/lib/appwrite/bank-account-actions";
|
||||||
import { formatTRY } from "@/lib/format";
|
import { formatTRY } from "@/lib/format";
|
||||||
|
import { ScopeBadge } from "@/components/finance/scope-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { BankFormSheet } from "./bank-form-sheet";
|
import { BankFormSheet } from "./bank-form-sheet";
|
||||||
@@ -229,6 +230,7 @@ function AccountCard({
|
|||||||
Arşivli
|
Arşivli
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<ScopeBadge scope={account.scope} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
|
<p className="text-muted-foreground mt-0.5 truncate text-sm">{account.accountName}</p>
|
||||||
{account.iban && (
|
{account.iban && (
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export type BankAccountRow = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
scope: "company" | "personal";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export default async function BanksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [accounts, balances] = await Promise.all([
|
const [accounts, balances] = await Promise.all([
|
||||||
listBankAccounts(ctx.tenantId),
|
listBankAccounts(ctx.tenantId, ctx.user.id),
|
||||||
getBankAccountBalances(ctx.tenantId),
|
getBankAccountBalances(ctx.tenantId, ctx.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +45,7 @@ export default async function BanksPage() {
|
|||||||
notes: a.notes ?? "",
|
notes: a.notes ?? "",
|
||||||
archived: Boolean(a.archived),
|
archived: Boolean(a.archived),
|
||||||
balance: balances.get(a.$id) ?? a.openingBalance ?? 0,
|
balance: balances.get(a.$id) ?? a.openingBalance ?? 0,
|
||||||
|
scope: (a.scope ?? "company") as "company" | "personal",
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
updateCreditCardAction,
|
updateCreditCardAction,
|
||||||
} from "@/lib/appwrite/credit-card-actions";
|
} from "@/lib/appwrite/credit-card-actions";
|
||||||
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
|
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
|
||||||
|
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||||
|
|
||||||
import type { BankAccountOption, CreditCardRow } from "./types";
|
import type { BankAccountOption, CreditCardRow } from "./types";
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ export function CardFormSheet({
|
|||||||
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
|
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
<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-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="bankName">Banka *</Label>
|
<Label htmlFor="bankName">Banka *</Label>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type CreditCardRow = {
|
|||||||
bankAccountLabel: string;
|
bankAccountLabel: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
scope: "company" | "personal";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatementRow = {
|
export type StatementRow = {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ export default async function CardsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [cards, statements, bankAccounts] = await Promise.all([
|
const [cards, statements, bankAccounts] = await Promise.all([
|
||||||
listCreditCards(ctx.tenantId),
|
listCreditCards(ctx.tenantId, ctx.user.id),
|
||||||
listStatements(ctx.tenantId),
|
listStatements(ctx.tenantId, ctx.user.id),
|
||||||
listBankAccounts(ctx.tenantId),
|
listBankAccounts(ctx.tenantId, ctx.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bankMap = new Map(
|
const bankMap = new Map(
|
||||||
@@ -55,6 +55,7 @@ export default async function CardsPage() {
|
|||||||
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
|
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
|
||||||
archived: Boolean(c.archived),
|
archived: Boolean(c.archived),
|
||||||
notes: c.notes ?? "",
|
notes: c.notes ?? "",
|
||||||
|
scope: (c.scope ?? "company") as "company" | "personal",
|
||||||
}))}
|
}))}
|
||||||
statements={statements.map((s) => ({
|
statements={statements.map((s) => ({
|
||||||
id: s.$id,
|
id: s.$id,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
updateFinanceEntryAction,
|
updateFinanceEntryAction,
|
||||||
} from "@/lib/appwrite/finance-actions";
|
} from "@/lib/appwrite/finance-actions";
|
||||||
import { initialFinanceState } from "@/lib/appwrite/finance-types";
|
import { initialFinanceState } from "@/lib/appwrite/finance-types";
|
||||||
|
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||||
|
|
||||||
import type { BankAccountOption, Customer, FinanceRow, FinanceType } from "./types";
|
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} />}
|
{isEdit && entry && <input type="hidden" name="id" value={entry.id} />}
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
<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-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="type">Tür *</Label>
|
<Label htmlFor="type">Tür *</Label>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { createLoanAction } from "@/lib/appwrite/loan-actions";
|
import { createLoanAction } from "@/lib/appwrite/loan-actions";
|
||||||
import { initialLoanState } from "@/lib/appwrite/loan-types";
|
import { initialLoanState } from "@/lib/appwrite/loan-types";
|
||||||
import { formatTRY } from "@/lib/format";
|
import { formatTRY } from "@/lib/format";
|
||||||
|
import { ScopeToggle } from "@/components/finance/scope-toggle";
|
||||||
|
|
||||||
import type { BankAccountOption } from "./types";
|
import type { BankAccountOption } from "./types";
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ export function LoanFormSheet({
|
|||||||
className="flex flex-1 flex-col"
|
className="flex flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
<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-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="bankName">Banka *</Label>
|
<Label htmlFor="bankName">Banka *</Label>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type LoanRow = {
|
|||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
remainingCount: number;
|
remainingCount: number;
|
||||||
nextDue: string | null;
|
nextDue: string | null;
|
||||||
|
scope: "company" | "personal";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InstallmentRow = {
|
export type InstallmentRow = {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export default async function LoansPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [loans, installments, bankAccounts] = await Promise.all([
|
const [loans, installments, bankAccounts] = await Promise.all([
|
||||||
listLoans(ctx.tenantId),
|
listLoans(ctx.tenantId, ctx.user.id),
|
||||||
listAllInstallments(ctx.tenantId),
|
listAllInstallments(ctx.tenantId, ctx.user.id),
|
||||||
listBankAccounts(ctx.tenantId),
|
listBankAccounts(ctx.tenantId, ctx.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bankMap = new Map(
|
const bankMap = new Map(
|
||||||
@@ -90,6 +90,7 @@ export default async function LoansPage() {
|
|||||||
paidAmount: m.paidAmount,
|
paidAmount: m.paidAmount,
|
||||||
remainingCount: m.remainingCount,
|
remainingCount: m.remainingCount,
|
||||||
nextDue: m.nextDue,
|
nextDue: m.nextDue,
|
||||||
|
scope: (l.scope ?? "company") as "company" | "personal",
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
installments={installments.map((i) => ({
|
installments={installments.map((i) => ({
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export default async function FinancePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [entries, customers, bankAccounts] = await Promise.all([
|
const [entries, customers, bankAccounts] = await Promise.all([
|
||||||
listFinanceEntries(ctx.tenantId),
|
listFinanceEntries(ctx.tenantId, ctx.user.id),
|
||||||
listCustomers(ctx.tenantId),
|
listCustomers(ctx.tenantId),
|
||||||
listBankAccounts(ctx.tenantId),
|
listBankAccounts(ctx.tenantId, ctx.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const customerMap = new Map(customers.map((c) => [c.$id, c.name]));
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
import { DATABASE_ID, TABLES, type BankAccount } from "./schema";
|
import { DATABASE_ID, TABLES, type BankAccount } from "./schema";
|
||||||
|
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
import type { BankAccountActionState } from "./bank-account-types";
|
import type { BankAccountActionState } from "./bank-account-types";
|
||||||
@@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
|
|||||||
return out;
|
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) {
|
function pickFormFields(formData: FormData) {
|
||||||
return {
|
return {
|
||||||
bankName: String(formData.get("bankName") ?? "").trim(),
|
bankName: String(formData.get("bankName") ?? "").trim(),
|
||||||
@@ -41,6 +33,7 @@ function pickFormFields(formData: FormData) {
|
|||||||
iban: String(formData.get("iban") ?? "").trim(),
|
iban: String(formData.get("iban") ?? "").trim(),
|
||||||
openingBalance: String(formData.get("openingBalance") ?? "0"),
|
openingBalance: String(formData.get("openingBalance") ?? "0"),
|
||||||
notes: String(formData.get("notes") ?? "").trim(),
|
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,
|
createdBy: ctx.user.id,
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||||
);
|
);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -116,11 +109,17 @@ export async function updateBankAccountAction(
|
|||||||
TABLES.bankAccounts,
|
TABLES.bankAccounts,
|
||||||
id,
|
id,
|
||||||
)) as unknown as BankAccount;
|
)) 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." };
|
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({
|
await logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
@@ -158,7 +157,7 @@ export async function archiveBankAccountAction(
|
|||||||
TABLES.bankAccounts,
|
TABLES.bankAccounts,
|
||||||
id,
|
id,
|
||||||
)) as unknown as BankAccount;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +202,7 @@ export async function deleteBankAccountAction(
|
|||||||
TABLES.bankAccounts,
|
TABLES.bankAccounts,
|
||||||
id,
|
id,
|
||||||
)) as unknown as BankAccount;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { canAccessRow } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -10,7 +11,15 @@ import {
|
|||||||
type FinanceEntry,
|
type FinanceEntry,
|
||||||
} from "./schema";
|
} 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 {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const result = await tablesDB.listRows({
|
||||||
@@ -22,30 +31,28 @@ export async function listBankAccounts(tenantId: string): Promise<BankAccount[]>
|
|||||||
Query.limit(200),
|
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 {
|
} catch {
|
||||||
return [];
|
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(
|
export async function getBankAccountBalances(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
|
currentUserId?: string,
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Map<string, number>> {
|
||||||
const balances = new Map<string, number>();
|
const balances = new Map<string, number>();
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const accounts = await listBankAccounts(tenantId, currentUserId);
|
||||||
const accounts = await tablesDB.listRows({
|
const visibleIds = new Set(accounts.map((a) => a.$id));
|
||||||
databaseId: DATABASE_ID,
|
for (const a of accounts) balances.set(a.$id, a.openingBalance ?? 0);
|
||||||
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 { tablesDB } = createAdminClient();
|
||||||
const entries = await tablesDB.listRows({
|
const entries = await tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.financeEntries,
|
tableId: TABLES.financeEntries,
|
||||||
@@ -56,12 +63,11 @@ export async function getBankAccountBalances(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
for (const e of entries.rows as unknown as FinanceEntry[]) {
|
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);
|
const cur = balances.get(e.bankAccountId);
|
||||||
if (cur === undefined) continue;
|
if (cur === undefined) continue;
|
||||||
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
|
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
|
||||||
else if (e.type === "expense") 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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type CreditCard,
|
type CreditCard,
|
||||||
type CreditCardStatement,
|
type CreditCardStatement,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
import type { CreditCardActionState } from "./credit-card-types";
|
import type { CreditCardActionState } from "./credit-card-types";
|
||||||
@@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
|
|||||||
return out;
|
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 {
|
function toIso(v: string): string {
|
||||||
if (!v) return v;
|
if (!v) return v;
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
|
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"),
|
interestRate: String(formData.get("interestRate") ?? "4.25"),
|
||||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||||
notes: String(formData.get("notes") ?? "").trim(),
|
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,
|
createdBy: ctx.user.id,
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||||
);
|
);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -133,11 +126,17 @@ export async function updateCreditCardAction(
|
|||||||
TABLES.creditCards,
|
TABLES.creditCards,
|
||||||
id,
|
id,
|
||||||
)) as unknown as CreditCard;
|
)) 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." };
|
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({
|
await logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
@@ -175,7 +174,7 @@ export async function archiveCreditCardAction(
|
|||||||
TABLES.creditCards,
|
TABLES.creditCards,
|
||||||
id,
|
id,
|
||||||
)) as unknown as CreditCard;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
const newArchivedState = !existing.archived;
|
const newArchivedState = !existing.archived;
|
||||||
@@ -219,7 +218,7 @@ export async function deleteCreditCardAction(
|
|||||||
TABLES.creditCards,
|
TABLES.creditCards,
|
||||||
id,
|
id,
|
||||||
)) as unknown as CreditCard;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,11 +309,13 @@ export async function createStatementAction(
|
|||||||
TABLES.creditCards,
|
TABLES.creditCards,
|
||||||
parsed.data.cardId,
|
parsed.data.cardId,
|
||||||
)) as unknown as CreditCard;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = computeStatus(parsed.data.totalDebt, 0, parsed.data.dueDate);
|
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(
|
const row = await tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -333,7 +334,7 @@ export async function createStatementAction(
|
|||||||
status,
|
status,
|
||||||
notes: parsed.data.notes,
|
notes: parsed.data.notes,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
|
||||||
);
|
);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -393,6 +394,10 @@ export async function payStatementAction(formData: FormData): Promise<CreditCard
|
|||||||
TABLES.creditCards,
|
TABLES.creditCards,
|
||||||
existing.cardId,
|
existing.cardId,
|
||||||
)) as unknown as CreditCard;
|
)) 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(
|
const fe = await tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -406,8 +411,9 @@ export async function payStatementAction(formData: FormData): Promise<CreditCard
|
|||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
|
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
|
||||||
bankAccountId: card.bankAccountId,
|
bankAccountId: card.bankAccountId,
|
||||||
|
scope: cardScope,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, cardScope),
|
||||||
);
|
);
|
||||||
|
|
||||||
const newPaid = (existing.paidAmount ?? 0) + payAmount;
|
const newPaid = (existing.paidAmount ?? 0) + payAmount;
|
||||||
@@ -459,6 +465,15 @@ export async function deleteStatementAction(
|
|||||||
if (existing.tenantId !== ctx.tenantId) {
|
if (existing.tenantId !== ctx.tenantId) {
|
||||||
return { ok: false, error: "Erişim engellendi." };
|
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) {
|
if (existing.financeEntryId) {
|
||||||
try {
|
try {
|
||||||
await tablesDB.deleteRow(
|
await tablesDB.deleteRow(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { canAccessRow } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -10,7 +11,10 @@ import {
|
|||||||
type CreditCardStatement,
|
type CreditCardStatement,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
|
export async function listCreditCards(
|
||||||
|
tenantId: string,
|
||||||
|
currentUserId?: string,
|
||||||
|
): Promise<CreditCard[]> {
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const result = await tablesDB.listRows({
|
||||||
@@ -22,18 +26,25 @@ export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
|
|||||||
Query.limit(200),
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists statements whose parent card is visible to the user.
|
||||||
|
*/
|
||||||
export async function listStatements(
|
export async function listStatements(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
|
currentUserId?: string,
|
||||||
): Promise<CreditCardStatement[]> {
|
): Promise<CreditCardStatement[]> {
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const [allStmt, allCards] = await Promise.all([
|
||||||
|
tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.creditCardStatements,
|
tableId: TABLES.creditCardStatements,
|
||||||
queries: [
|
queries: [
|
||||||
@@ -41,8 +52,22 @@ export async function listStatements(
|
|||||||
Query.orderDesc("statementDate"),
|
Query.orderDesc("statementDate"),
|
||||||
Query.limit(500),
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,11 @@ export async function getDashboardData(
|
|||||||
|
|
||||||
const customerList = customers.rows as unknown as Customer[];
|
const customerList = customers.rows as unknown as Customer[];
|
||||||
const invoiceList = invoices.rows as unknown as Invoice[];
|
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 taskList = tasks.rows as unknown as Task[];
|
||||||
|
|
||||||
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
|
const customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
|
import { AppwriteException, ID } from "node-appwrite";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
||||||
|
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
import type { FinanceActionState } from "./finance-types";
|
import type { FinanceActionState } from "./finance-types";
|
||||||
@@ -25,15 +26,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
|
|||||||
return out;
|
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) {
|
function pickFormFields(formData: FormData) {
|
||||||
return {
|
return {
|
||||||
type: formData.get("type") as "income" | "expense" | "debt" | "receivable",
|
type: formData.get("type") as "income" | "expense" | "debt" | "receivable",
|
||||||
@@ -50,6 +42,7 @@ function pickFormFields(formData: FormData) {
|
|||||||
| "other"
|
| "other"
|
||||||
| null,
|
| null,
|
||||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
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,
|
createdBy: ctx.user.id,
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, parsed.data.scope),
|
||||||
);
|
);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -132,12 +125,18 @@ export async function updateFinanceEntryAction(
|
|||||||
TABLES.financeEntries,
|
TABLES.financeEntries,
|
||||||
id,
|
id,
|
||||||
)) as unknown as FinanceEntry;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = { ...parsed.data, date: toIso(parsed.data.date) };
|
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({
|
await logAudit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
@@ -175,7 +174,7 @@ export async function deleteFinanceEntryAction(
|
|||||||
TABLES.financeEntries,
|
TABLES.financeEntries,
|
||||||
id,
|
id,
|
||||||
)) as unknown as FinanceEntry;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import "server-only";
|
|||||||
|
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { canAccessRow } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
|
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 {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const result = await tablesDB.listRows({
|
||||||
@@ -17,7 +21,9 @@ export async function listFinanceEntries(tenantId: string): Promise<FinanceEntry
|
|||||||
Query.limit(1000),
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,14 +183,25 @@ export async function getFinancialReport(
|
|||||||
.catch(() => ({ rows: [] as unknown[] })),
|
.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 customerList = customers.rows as unknown as Customer[];
|
||||||
const invoiceList = invoices.rows as unknown as Invoice[];
|
const invoiceList = invoices.rows as unknown as Invoice[];
|
||||||
const entryList = finance.rows as unknown as FinanceEntry[];
|
const entryList = (finance.rows as unknown as FinanceEntry[]).filter(isCompany);
|
||||||
const bankList = bankAccounts.rows as unknown as BankAccount[];
|
const bankList = (bankAccounts.rows as unknown as BankAccount[]).filter(isCompany);
|
||||||
const loanList = loans.rows as unknown as BankLoan[];
|
const loanList = (loans.rows as unknown as BankLoan[]).filter(isCompany);
|
||||||
const installmentList = installments.rows as unknown as LoanInstallment[];
|
const cardList = (cards.rows as unknown as CreditCard[]).filter(isCompany);
|
||||||
const cardList = cards.rows as unknown as CreditCard[];
|
const visibleLoanIds = new Set(loanList.map((l) => l.$id));
|
||||||
const statementList = statements.rows as unknown as CreditCardStatement[];
|
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 customerMap = new Map(customerList.map((c) => [c.$id, c.name]));
|
||||||
const cardMap = new Map(
|
const cardMap = new Map(
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ async function syncPaymentEntry(
|
|||||||
description: `Fatura ${invoice.number} tahsilatı`,
|
description: `Fatura ${invoice.number} tahsilatı`,
|
||||||
customerId: invoice.customerId,
|
customerId: invoice.customerId,
|
||||||
invoiceId: invoice.$id,
|
invoiceId: invoice.$id,
|
||||||
|
scope: "company",
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
Permission.read(Role.team(tenantId)),
|
Permission.read(Role.team(tenantId)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type BankLoan,
|
type BankLoan,
|
||||||
type LoanInstallment,
|
type LoanInstallment,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { requireTenant } from "./tenant-guard";
|
import { requireTenant } from "./tenant-guard";
|
||||||
import type { LoanActionState } from "./loan-types";
|
import type { LoanActionState } from "./loan-types";
|
||||||
@@ -30,15 +31,6 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
|
|||||||
return out;
|
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) {
|
function pickLoanFields(formData: FormData) {
|
||||||
return {
|
return {
|
||||||
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
bankAccountId: String(formData.get("bankAccountId") ?? ""),
|
||||||
@@ -58,6 +50,7 @@ function pickLoanFields(formData: FormData) {
|
|||||||
startDate: String(formData.get("startDate") ?? ""),
|
startDate: String(formData.get("startDate") ?? ""),
|
||||||
paymentDay: String(formData.get("paymentDay") ?? "1"),
|
paymentDay: String(formData.get("paymentDay") ?? "1"),
|
||||||
notes: String(formData.get("notes") ?? "").trim(),
|
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;
|
let loanId: string | null = null;
|
||||||
const admin = createAdminClient();
|
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 {
|
try {
|
||||||
const loan = await admin.tablesDB.createRow(
|
const loan = await admin.tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -158,8 +154,9 @@ export async function createLoanAction(
|
|||||||
paymentDay: parsed.data.paymentDay,
|
paymentDay: parsed.data.paymentDay,
|
||||||
status: "active",
|
status: "active",
|
||||||
notes: parsed.data.notes,
|
notes: parsed.data.notes,
|
||||||
|
scope: parsed.data.scope,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
rowPerms,
|
||||||
);
|
);
|
||||||
loanId = loan.$id;
|
loanId = loan.$id;
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ export async function createLoanAction(
|
|||||||
interestPart: slice.interestPart,
|
interestPart: slice.interestPart,
|
||||||
paid: false,
|
paid: false,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
rowPerms,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +236,7 @@ export async function deleteLoanAction(formData: FormData): Promise<LoanActionSt
|
|||||||
TABLES.bankLoans,
|
TABLES.bankLoans,
|
||||||
id,
|
id,
|
||||||
)) as unknown as BankLoan;
|
)) 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." };
|
return { ok: false, error: "Erişim engellendi." };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +302,13 @@ export async function payInstallmentAction(formData: FormData): Promise<LoanActi
|
|||||||
TABLES.bankLoans,
|
TABLES.bankLoans,
|
||||||
existing.loanId,
|
existing.loanId,
|
||||||
)) as unknown as BankLoan;
|
)) 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(
|
const fe = await tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
TABLES.financeEntries,
|
TABLES.financeEntries,
|
||||||
@@ -319,8 +321,9 @@ export async function payInstallmentAction(formData: FormData): Promise<LoanActi
|
|||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
description: `${loan.bankName} — ${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
|
description: `${loan.bankName} — ${loan.loanName} #${existing.installmentNo} taksit ödemesi`,
|
||||||
bankAccountId: loan.bankAccountId,
|
bankAccountId: loan.bankAccountId,
|
||||||
|
scope: loanScope,
|
||||||
},
|
},
|
||||||
teamRowPermissions(ctx.tenantId),
|
scopedRowPermissions(ctx.tenantId, ctx.user.id, loanScope),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
|
await tablesDB.updateRow(DATABASE_ID, TABLES.loanInstallments, id, {
|
||||||
@@ -388,6 +391,16 @@ export async function unpayInstallmentAction(
|
|||||||
}
|
}
|
||||||
if (!existing.paid) return { ok: true };
|
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) {
|
if (existing.financeEntryId) {
|
||||||
try {
|
try {
|
||||||
await tablesDB.deleteRow(
|
await tablesDB.deleteRow(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { canAccessRow } from "./scope-permissions";
|
||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -10,7 +11,10 @@ import {
|
|||||||
type LoanInstallment,
|
type LoanInstallment,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export async function listLoans(tenantId: string): Promise<BankLoan[]> {
|
export async function listLoans(
|
||||||
|
tenantId: string,
|
||||||
|
currentUserId?: string,
|
||||||
|
): Promise<BankLoan[]> {
|
||||||
try {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const result = await tablesDB.listRows({
|
||||||
@@ -22,7 +26,9 @@ export async function listLoans(tenantId: string): Promise<BankLoan[]> {
|
|||||||
Query.limit(200),
|
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 {
|
} catch {
|
||||||
return [];
|
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 {
|
try {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
const [allInst, allLoans] = await Promise.all([
|
||||||
|
tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.loanInstallments,
|
tableId: TABLES.loanInstallments,
|
||||||
queries: [Query.equal("tenantId", tenantId), Query.limit(2000)],
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface FinanceEntry extends Row {
|
|||||||
invoiceId?: string;
|
invoiceId?: string;
|
||||||
paymentMethod?: PaymentMethod;
|
paymentMethod?: PaymentMethod;
|
||||||
bankAccountId?: string;
|
bankAccountId?: string;
|
||||||
|
scope?: "company" | "personal";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
|
export type InvoiceStatus = "draft" | "sent" | "paid" | "overdue" | "cancelled";
|
||||||
@@ -181,6 +182,8 @@ export interface AuditLog extends Row {
|
|||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FinanceScope = "company" | "personal";
|
||||||
|
|
||||||
export interface BankAccount extends Row {
|
export interface BankAccount extends Row {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
@@ -190,6 +193,7 @@ export interface BankAccount extends Row {
|
|||||||
openingBalance?: number;
|
openingBalance?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
|
scope?: FinanceScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
|
export type LoanType = "consumer" | "vehicle" | "housing" | "commercial" | "kmh" | "other";
|
||||||
@@ -210,6 +214,7 @@ export interface BankLoan extends Row {
|
|||||||
paymentDay?: number;
|
paymentDay?: number;
|
||||||
status?: LoanStatus;
|
status?: LoanStatus;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
scope?: FinanceScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoanInstallment extends Row {
|
export interface LoanInstallment extends Row {
|
||||||
@@ -238,6 +243,7 @@ export interface CreditCard extends Row {
|
|||||||
bankAccountId?: string;
|
bankAccountId?: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
scope?: FinanceScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
|
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)
|
.max(500)
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v ? v : undefined)),
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
scope: z.enum(["company", "personal"]).optional().default("company"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BankAccountInput = z.infer<typeof bankAccountSchema>;
|
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;
|
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1;
|
||||||
}),
|
}),
|
||||||
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
|
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>;
|
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)),
|
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
|
||||||
notes: z.string().trim().max(500).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>;
|
export type CreditCardInput = z.infer<typeof creditCardSchema>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const financeEntrySchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v || undefined),
|
.transform((v) => v || undefined),
|
||||||
bankAccountId: z.string().optional().transform((v) => (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>;
|
export type FinanceEntryInput = z.infer<typeof financeEntrySchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user