feat(finance): connection-based balances + lump-sum payment recording
Klinik-laboratuvar finance in TR is dönemsel, not job-by-job. Forcing
the lab to mark each finance_entry as paid was unrealistic — labs get a
single 50.000 TL transfer covering twelve jobs and don't want to walk a
list. Reworked the page around connection balances and free-amount
payments.
Data model
- New 'payments' table:
tenantId whose ledger it lives in
counterpartTenantId the other side of the transaction
direction inflow (lab received) | outflow (clinic paid)
amount, currency, paymentDate
method cash | bank | card | check | other
notes, recordedBy
Indexes: (tenantId, counterpartTenantId), paymentDate DESC. Permission:
both teams can read, only owners/admins of the recording side can
update or delete.
Server
- recordPaymentAction: requires owner/admin, verifies an approved
connection exists between (lab, clinic), then writes a single row.
Direction is inferred from the caller's tenant kind so a lab can
never accidentally book an outflow.
- deletePaymentAction: same auth, tenant-scoped delete.
- listPayments(tenantId) + computeBalancesByCounterpart({ kind,
entries, payments }): one pass over both ledgers, returns
[{ counterpartTenantId, invoiced, paid, open, lastPaymentAt }]
sorted by open desc. invoiced pulls from finance_entries (receivable
for lab, debt for clinic); paid pulls from the new payments table.
UI
- /finance now leads with a Bakiye kartı: a row per connected
counterpart showing invoiced, paid, last payment date and the open
amount tinted green (lab alacak) or red (clinic borç), each with an
inline 'Ödeme Al' / 'Ödeme Yap' button.
- RecordPaymentDialog: amount (defaults to the open balance, lump
sums obviously not pre-filled with a specific entry), date,
currency, method (Nakit/Banka/Kart/Çek/Diğer), free-text note.
Posts to recordPaymentAction, refresh on success.
- Stat cards reworked: Toplam Açık Alacak/Borç and Tahsil Edilen /
Ödenen replaced the old pending-totals so the headline numbers
actually reflect the new flow.
Existing finance_entries (job-driven receivables/debts) remain the
single source of truth for 'how much was invoiced'; the new table tracks
'how much was actually collected'. Open balance = invoiced - paid, always
computed live — no individual entry needs to be marked 'paid' anymore.
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { CounterpartBalance } from "@/lib/appwrite/payment-queries";
|
||||||
|
import { RecordPaymentDialog } from "./record-payment-dialog";
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function BalancesCard({
|
||||||
|
balances,
|
||||||
|
counterpartNames,
|
||||||
|
selfKind,
|
||||||
|
defaultCurrency,
|
||||||
|
}: {
|
||||||
|
balances: CounterpartBalance[];
|
||||||
|
counterpartNames: Record<string, string>;
|
||||||
|
selfKind: "lab" | "clinic";
|
||||||
|
defaultCurrency: string;
|
||||||
|
}) {
|
||||||
|
const isLab = selfKind === "lab";
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isLab ? "Klinik Bakiyeleri" : "Laboratuvar Bakiyeleri"}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isLab
|
||||||
|
? "Her klinik için açık bakiye ve son tahsilatlar. Toplu ödemeleri buradan girebilirsiniz."
|
||||||
|
: "Çalıştığınız her laboratuvar için açık bakiye ve son ödemeleriniz."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{balances.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||||
|
Bağlantılarınız için henüz finansal hareket yok.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{balances.map((b) => {
|
||||||
|
const name = counterpartNames[b.counterpartTenantId] ?? "—";
|
||||||
|
const settled = b.open <= 0.01;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={b.counterpartTenantId}
|
||||||
|
className="flex flex-wrap items-center gap-3 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Fatura: {formatMoney(b.invoiced, b.currency)} · Ödenen:{" "}
|
||||||
|
{formatMoney(b.paid, b.currency)}
|
||||||
|
{b.lastPaymentAt && (
|
||||||
|
<>
|
||||||
|
{" "}· Son ödeme:{" "}
|
||||||
|
{dateFormatter.format(new Date(b.lastPaymentAt))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-base font-semibold tabular-nums ${
|
||||||
|
settled
|
||||||
|
? "text-muted-foreground"
|
||||||
|
: isLab
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "text-rose-600 dark:text-rose-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatMoney(b.open, b.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{settled ? "Kapalı" : isLab ? "Açık alacak" : "Açık borç"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RecordPaymentDialog
|
||||||
|
counterpartTenantId={b.counterpartTenantId}
|
||||||
|
counterpartName={name}
|
||||||
|
selfKind={selfKind}
|
||||||
|
defaultCurrency={b.currency || defaultCurrency}
|
||||||
|
openAmount={b.open > 0 ? b.open : undefined}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Banknote, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { recordPaymentAction } from "@/lib/appwrite/payment-actions";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHOD_OPTIONS,
|
||||||
|
initialPaymentFormState,
|
||||||
|
} from "@/lib/appwrite/payment-types";
|
||||||
|
|
||||||
|
export function RecordPaymentDialog({
|
||||||
|
counterpartTenantId,
|
||||||
|
counterpartName,
|
||||||
|
selfKind,
|
||||||
|
defaultCurrency,
|
||||||
|
openAmount,
|
||||||
|
triggerLabel,
|
||||||
|
}: {
|
||||||
|
counterpartTenantId: string;
|
||||||
|
counterpartName: string;
|
||||||
|
selfKind: "lab" | "clinic";
|
||||||
|
defaultCurrency: string;
|
||||||
|
openAmount?: number;
|
||||||
|
triggerLabel?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [state, action, pending] = useActionState(
|
||||||
|
recordPaymentAction,
|
||||||
|
initialPaymentFormState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.ok) {
|
||||||
|
toast.success("Ödeme kaydedildi.");
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
} else if (state.error) {
|
||||||
|
toast.error(state.error);
|
||||||
|
}
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
|
const label = triggerLabel ?? (selfKind === "lab" ? "Ödeme Al" : "Ödeme Yap");
|
||||||
|
const title = selfKind === "lab" ? "Tahsilat Kaydı" : "Ödeme Kaydı";
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<Button size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Banknote className="size-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title} — {counterpartName}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selfKind === "lab"
|
||||||
|
? "Bu kliniğin yaptığı toplu ödemeyi kaydedin. Açık bakiyeden otomatik düşülür."
|
||||||
|
: "Bu laboratuvara yaptığınız ödemeyi kaydedin."}
|
||||||
|
{typeof openAmount === "number" && (
|
||||||
|
<>
|
||||||
|
{" "}Açık bakiye:{" "}
|
||||||
|
<strong className="tabular-nums">
|
||||||
|
{openAmount.toLocaleString("tr-TR")} {defaultCurrency}
|
||||||
|
</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={action} className="grid gap-3">
|
||||||
|
<input type="hidden" name="counterpartTenantId" value={counterpartTenantId} />
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_100px] gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="amount">Tutar *</Label>
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
defaultValue={openAmount ?? undefined}
|
||||||
|
/>
|
||||||
|
{state.fieldErrors?.amount && (
|
||||||
|
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="currency">Para</Label>
|
||||||
|
<Input
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
defaultValue={defaultCurrency}
|
||||||
|
maxLength={8}
|
||||||
|
style={{ textTransform: "uppercase" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="paymentDate">Tarih</Label>
|
||||||
|
<Input
|
||||||
|
id="paymentDate"
|
||||||
|
name="paymentDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={today}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="method">Yöntem</Label>
|
||||||
|
<Select name="method" defaultValue="bank">
|
||||||
|
<SelectTrigger id="method">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAYMENT_METHOD_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notes">Not (opsiyonel)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
rows={2}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Örn. Ağustos toplu, dekont no 12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Vazgeç
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="size-4 animate-spin" /> : <Banknote className="size-4" />}
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
|
||||||
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
||||||
|
import {
|
||||||
|
computeBalancesByCounterpart,
|
||||||
|
listPayments,
|
||||||
|
} from "@/lib/appwrite/payment-queries";
|
||||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||||
|
import { BalancesCard } from "./components/balances-card";
|
||||||
import { FinanceTable } from "./components/finance-table";
|
import { FinanceTable } from "./components/finance-table";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -24,26 +30,56 @@ export default async function FinancePage() {
|
|||||||
} catch {
|
} catch {
|
||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
}
|
}
|
||||||
|
if (!ctx.kind) redirect("/onboarding");
|
||||||
|
const kind = ctx.kind;
|
||||||
|
|
||||||
|
const [entries, payments, connections] = await Promise.all([
|
||||||
|
listFinanceEntries(ctx.tenantId),
|
||||||
|
listPayments(ctx.tenantId),
|
||||||
|
listApprovedConnections(ctx.tenantId),
|
||||||
|
]);
|
||||||
|
|
||||||
const entries = await listFinanceEntries(ctx.tenantId);
|
|
||||||
const stats = summarizeFinance(entries);
|
const stats = summarizeFinance(entries);
|
||||||
const isLab = ctx.kind === "lab";
|
const isLab = kind === "lab";
|
||||||
|
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||||
|
|
||||||
|
const balances = computeBalancesByCounterpart({ kind, entries, payments });
|
||||||
|
const counterpartNames: Record<string, string> = {};
|
||||||
|
for (const c of connections) {
|
||||||
|
const id = isLab ? c.clinicTenantId : c.labTenantId;
|
||||||
|
counterpartNames[id] = c.counterpart?.companyName ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPaid = payments
|
||||||
|
.filter((p) => p.direction === (isLab ? "inflow" : "outflow"))
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const totalOpen = balances.reduce(
|
||||||
|
(sum, b) => sum + (b.open > 0 ? b.open : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-6 px-6">
|
<div className="flex-1 space-y-6 px-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Finans</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}
|
{isLab
|
||||||
|
? "Klinik bazlı açık bakiye ve toplu tahsilat."
|
||||||
|
: "Laboratuvar bazlı borç ve ödeme akışı."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"}
|
label={isLab ? "Toplam Açık Alacak" : "Toplam Açık Borç"}
|
||||||
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)}
|
value={formatMoney(totalOpen, defaultCurrency)}
|
||||||
tone={isLab ? "positive" : "negative"}
|
tone={isLab ? "positive" : "negative"}
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={isLab ? "Tahsil Edilen" : "Ödenen"}
|
||||||
|
value={formatMoney(totalPaid, defaultCurrency)}
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Bu Ay Gelir"
|
label="Bu Ay Gelir"
|
||||||
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
value={formatMoney(stats.incomeThisMonth, stats.currency)}
|
||||||
@@ -54,18 +90,20 @@ export default async function FinancePage() {
|
|||||||
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
value={formatMoney(stats.expenseThisMonth, stats.currency)}
|
||||||
tone="negative"
|
tone="negative"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
label="Toplam Kayıt"
|
|
||||||
value={String(entries.length)}
|
|
||||||
tone="neutral"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BalancesCard
|
||||||
|
balances={balances}
|
||||||
|
counterpartNames={counterpartNames}
|
||||||
|
selfKind={kind}
|
||||||
|
defaultCurrency={defaultCurrency}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Hareketler</CardTitle>
|
<CardTitle>Hareketler</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde.
|
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { logAudit } from "./audit";
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type Connection,
|
||||||
|
type Payment,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { requireRole, requireTenant } from "./tenant-guard";
|
||||||
|
import type {
|
||||||
|
PaymentActionState,
|
||||||
|
PaymentFormState,
|
||||||
|
} from "./payment-types";
|
||||||
|
import { paymentSchema } from "@/lib/validation/payment";
|
||||||
|
|
||||||
|
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||||
|
if (e instanceof AppwriteException) return e.message || fallback;
|
||||||
|
return process.env.NODE_ENV !== "production" && e instanceof Error
|
||||||
|
? `${fallback} (${e.message})`
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenErrors(err: z.ZodError): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of err.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentPermissions(tenantId: string, counterpartTenantId: string): string[] {
|
||||||
|
return [
|
||||||
|
Permission.read(Role.team(tenantId)),
|
||||||
|
Permission.read(Role.team(counterpartTenantId)),
|
||||||
|
Permission.update(Role.team(tenantId, "owner")),
|
||||||
|
Permission.update(Role.team(tenantId, "admin")),
|
||||||
|
Permission.delete(Role.team(tenantId, "owner")),
|
||||||
|
Permission.delete(Role.team(tenantId, "admin")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFields(formData: FormData) {
|
||||||
|
return {
|
||||||
|
counterpartTenantId: String(formData.get("counterpartTenantId") ?? "").trim(),
|
||||||
|
amount: String(formData.get("amount") ?? "").trim(),
|
||||||
|
currency: String(formData.get("currency") ?? "").trim(),
|
||||||
|
paymentDate: String(formData.get("paymentDate") ?? "").trim(),
|
||||||
|
method: String(formData.get("method") ?? "").trim(),
|
||||||
|
notes: String(formData.get("notes") ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a payment between this tenant and a counterpart. Direction is
|
||||||
|
* determined by the caller's tenant kind:
|
||||||
|
* - lab → inflow (the lab received money from a clinic)
|
||||||
|
* - clinic → outflow (the clinic paid a lab)
|
||||||
|
*
|
||||||
|
* A single payment can cover many invoices at once — we do NOT walk back
|
||||||
|
* into finance_entries to settle individual rows. The open balance per
|
||||||
|
* connection is always computed live as (sum of receivables for that
|
||||||
|
* counterpart) - (sum of payments inflow from that counterpart) for the
|
||||||
|
* lab side, and the symmetric formula for the clinic side.
|
||||||
|
*/
|
||||||
|
export async function recordPaymentAction(
|
||||||
|
_prev: PaymentFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<PaymentFormState> {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Ödeme kaydı için yetkiniz yok." };
|
||||||
|
}
|
||||||
|
if (!ctx.kind) {
|
||||||
|
return { ok: false, error: "Tenant türü bilinmiyor." };
|
||||||
|
}
|
||||||
|
const direction = ctx.kind === "lab" ? "inflow" : "outflow";
|
||||||
|
|
||||||
|
const parsed = paymentSchema.safeParse(pickFields(formData));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
|
||||||
|
// Counterpart must be an approved connection — we never let a tenant
|
||||||
|
// record payments against an unconnected workspace.
|
||||||
|
const labId = ctx.kind === "lab" ? ctx.tenantId : parsed.data.counterpartTenantId;
|
||||||
|
const clinicId = ctx.kind === "lab" ? parsed.data.counterpartTenantId : ctx.tenantId;
|
||||||
|
const connRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.equal("labTenantId", labId),
|
||||||
|
Query.equal("clinicTenantId", clinicId),
|
||||||
|
Query.equal("status", "approved"),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!(connRes.rows[0] as unknown as Connection | undefined)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Onaylı bir bağlantı bulunamadı.",
|
||||||
|
fieldErrors: { counterpartTenantId: "Bağlantı yok." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await tablesDB.createRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.payments,
|
||||||
|
ID.unique(),
|
||||||
|
{
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
counterpartTenantId: parsed.data.counterpartTenantId,
|
||||||
|
direction,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
currency: parsed.data.currency,
|
||||||
|
paymentDate: parsed.data.paymentDate,
|
||||||
|
method: parsed.data.method,
|
||||||
|
notes: parsed.data.notes,
|
||||||
|
recordedBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
|
||||||
|
);
|
||||||
|
void logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "create",
|
||||||
|
entityType: "payment",
|
||||||
|
entityId: created.$id,
|
||||||
|
changes: {
|
||||||
|
direction,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
counterpartTenantId: parsed.data.counterpartTenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/finance");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePaymentAction(
|
||||||
|
_prev: PaymentActionState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<PaymentActionState> {
|
||||||
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
|
if (!id) return { ok: false, error: "Kayıt bulunamadı." };
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireRole(ctx, ["owner", "admin"]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const row = (await tablesDB.getRow(
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES.payments,
|
||||||
|
id,
|
||||||
|
)) as unknown as Payment;
|
||||||
|
if (row.tenantId !== ctx.tenantId) {
|
||||||
|
return { ok: false, error: "Yetkiniz yok." };
|
||||||
|
}
|
||||||
|
await tablesDB.deleteRow(DATABASE_ID, TABLES.payments, id);
|
||||||
|
void logAudit({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: "delete",
|
||||||
|
entityType: "payment",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: appwriteError(e, "Silinemedi.") };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/finance");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATABASE_ID,
|
||||||
|
TABLES,
|
||||||
|
type FinanceEntry,
|
||||||
|
type Payment,
|
||||||
|
type TenantKind,
|
||||||
|
} from "./schema";
|
||||||
|
import { createAdminClient } from "./server";
|
||||||
|
import { toPlain } from "./serialize";
|
||||||
|
|
||||||
|
export async function listPayments(tenantId: string): Promise<Payment[]> {
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const result = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.payments,
|
||||||
|
queries: [
|
||||||
|
Query.equal("tenantId", tenantId),
|
||||||
|
Query.orderDesc("paymentDate"),
|
||||||
|
Query.limit(500),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return toPlain(result.rows as unknown as Payment[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartBalance = {
|
||||||
|
counterpartTenantId: string;
|
||||||
|
currency: string;
|
||||||
|
/** sum of receivables (lab) or debts (clinic) from finance_entries */
|
||||||
|
invoiced: number;
|
||||||
|
/** sum of payments — inflow for lab, outflow for clinic */
|
||||||
|
paid: number;
|
||||||
|
/** invoiced - paid; positive means money is still owed to this tenant */
|
||||||
|
open: number;
|
||||||
|
/** most recent payment date if any, useful for sorting */
|
||||||
|
lastPaymentAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the open balance with each counterpart in a single pass over the
|
||||||
|
* already-loaded finance entries and payments. Lab side groups receivables
|
||||||
|
* and inflows by clinic; clinic side groups debts and outflows by lab.
|
||||||
|
*/
|
||||||
|
export function computeBalancesByCounterpart(args: {
|
||||||
|
kind: TenantKind;
|
||||||
|
entries: FinanceEntry[];
|
||||||
|
payments: Payment[];
|
||||||
|
}): CounterpartBalance[] {
|
||||||
|
const isLab = args.kind === "lab";
|
||||||
|
const invoiceType = isLab ? "receivable" : "debt";
|
||||||
|
const paymentDirection = isLab ? "inflow" : "outflow";
|
||||||
|
|
||||||
|
const acc = new Map<string, CounterpartBalance>();
|
||||||
|
const ensure = (id: string, currency: string): CounterpartBalance => {
|
||||||
|
const existing = acc.get(id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fresh: CounterpartBalance = {
|
||||||
|
counterpartTenantId: id,
|
||||||
|
currency,
|
||||||
|
invoiced: 0,
|
||||||
|
paid: 0,
|
||||||
|
open: 0,
|
||||||
|
};
|
||||||
|
acc.set(id, fresh);
|
||||||
|
return fresh;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const e of args.entries) {
|
||||||
|
if (e.type !== invoiceType) continue;
|
||||||
|
if (!e.counterpartTenantId) continue;
|
||||||
|
const row = ensure(e.counterpartTenantId, e.currency ?? "TRY");
|
||||||
|
row.invoiced += e.amount;
|
||||||
|
}
|
||||||
|
for (const p of args.payments) {
|
||||||
|
if (p.direction !== paymentDirection) continue;
|
||||||
|
const row = ensure(p.counterpartTenantId, p.currency);
|
||||||
|
row.paid += p.amount;
|
||||||
|
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
|
||||||
|
row.lastPaymentAt = p.paymentDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of acc.values()) {
|
||||||
|
row.open = row.invoiced - row.paid;
|
||||||
|
}
|
||||||
|
return Array.from(acc.values()).sort((a, b) => b.open - a.open);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type PaymentFormState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialPaymentFormState: PaymentFormState = { ok: false };
|
||||||
|
|
||||||
|
export type PaymentActionState = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialPaymentActionState: PaymentActionState = { ok: false };
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_OPTIONS = [
|
||||||
|
{ value: "cash", label: "Nakit" },
|
||||||
|
{ value: "bank", label: "Banka / Havale" },
|
||||||
|
{ value: "card", label: "Kart" },
|
||||||
|
{ value: "check", label: "Çek / Senet" },
|
||||||
|
{ value: "other", label: "Diğer" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]),
|
||||||
|
);
|
||||||
@@ -11,6 +11,7 @@ export const TABLES = {
|
|||||||
connections: "connections",
|
connections: "connections",
|
||||||
patients: "patients",
|
patients: "patients",
|
||||||
clinicPricing: "clinic_pricing",
|
clinicPricing: "clinic_pricing",
|
||||||
|
payments: "payments",
|
||||||
jobs: "jobs",
|
jobs: "jobs",
|
||||||
jobFiles: "job_files",
|
jobFiles: "job_files",
|
||||||
jobStatusHistory: "job_status_history",
|
jobStatusHistory: "job_status_history",
|
||||||
@@ -174,6 +175,20 @@ export interface FinanceEntry extends Row {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PaymentDirection = "inflow" | "outflow";
|
||||||
|
|
||||||
|
export interface Payment extends Row {
|
||||||
|
tenantId: string;
|
||||||
|
counterpartTenantId: string;
|
||||||
|
direction: PaymentDirection;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
paymentDate: string;
|
||||||
|
method?: string;
|
||||||
|
notes?: string;
|
||||||
|
recordedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Notification extends Row {
|
export interface Notification extends Row {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
function toNumber(v: unknown): number {
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
const n = Number(String(v ?? "").replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentSchema = z.object({
|
||||||
|
counterpartTenantId: z.string().min(1, "Karşı taraf seçin."),
|
||||||
|
amount: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.transform(toNumber)
|
||||||
|
.pipe(z.number().positive("Tutar 0'dan büyük olmalı.")),
|
||||||
|
currency: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(8)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||||
|
paymentDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? new Date(v).toISOString() : new Date().toISOString())),
|
||||||
|
method: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(30)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
notes: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1000)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? v : undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaymentInput = z.infer<typeof paymentSchema>;
|
||||||
Reference in New Issue
Block a user