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:
kovakmedya
2026-05-22 01:42:21 +03:00
parent 5dab958085
commit b1046e945a
8 changed files with 686 additions and 11 deletions
+194
View File
@@ -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 };
}
+89
View File
@@ -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);
}
+26
View File
@@ -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]),
);
+15
View File
@@ -11,6 +11,7 @@ export const TABLES = {
connections: "connections",
patients: "patients",
clinicPricing: "clinic_pricing",
payments: "payments",
jobs: "jobs",
jobFiles: "job_files",
jobStatusHistory: "job_status_history",
@@ -174,6 +175,20 @@ export interface FinanceEntry extends Row {
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 {
tenantId: string;
userId?: string;
+40
View File
@@ -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>;