feat(finance): clinic submits, lab confirms — payment approval flow

A payment recorded by the lab itself is self-evident (the lab knows it
got paid). One recorded by the clinic is just a claim until the lab
agrees. Added a status field to enforce that distinction so labs can
approve payments per-clinic instead of trusting whatever the clinic
typed in.

DB
  - payments.status enum (pending | confirmed | rejected, default
    confirmed). Existing rows keep the default and continue to be
    counted in balances.

Server
  - recordPaymentAction now stamps status='pending' when the caller is a
    clinic and 'confirmed' when the caller is a lab. A clinic submission
    pings the lab via createNotification so it surfaces on the
    notifications bell as well as on /finance.
  - confirmPaymentAction (lab only): flips a pending row to confirmed
    after verifying the lab is the counterpart. Notifies the clinic on
    success.
  - rejectPaymentAction (lab only): flips to rejected, notifies the
    clinic. Rejected rows stay visible for audit but never count toward
    the balance.

Queries
  - listIncomingPayments(tenantId) — payments where this tenant is the
    counterpart (the other side recorded them). Paired with listPayments
    we now see the same physical payment from either ledger.
  - computeBalancesByCounterpart upgraded to handle both shapes via an
    inflowFor() helper that normalises 'who got the money'. Only
    confirmed rows reduce the open balance.
  - filterPendingForConfirmation() returns the lab-side approval queue,
    sorted newest-first.

UI
  - /finance loads own + incoming payments, dedupes by $id, then feeds
    the merged list to balance/pending helpers.
  - New PendingPaymentsCard sits above the balances table on the lab
    side. Per-row: clinic name, amount, date, method, note, plus inline
    Onayla / Reddet buttons. Empty state hides the whole card.
  - Confirm + reject use the same router.refresh pattern as the rest of
    the action panels so the queue and the balances both update without
    a manual reload.
This commit is contained in:
kovakmedya
2026-05-22 01:47:10 +03:00
parent b1046e945a
commit 0e4033aa3f
5 changed files with 366 additions and 13 deletions
+127
View File
@@ -5,6 +5,7 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { createNotification } from "./notification-helpers";
import {
DATABASE_ID,
TABLES,
@@ -114,6 +115,11 @@ export async function recordPaymentAction(
};
}
// Lab-recorded payments are self-confirmed (the lab knows it received
// the money). Clinic-recorded payments enter as pending and require the
// lab to confirm before they're counted in the open balance.
const status = ctx.kind === "lab" ? "confirmed" : "pending";
try {
const created = await tablesDB.createRow(
DATABASE_ID,
@@ -128,6 +134,7 @@ export async function recordPaymentAction(
paymentDate: parsed.data.paymentDate,
method: parsed.data.method,
notes: parsed.data.notes,
status,
recordedBy: ctx.user.id,
},
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
@@ -142,8 +149,16 @@ export async function recordPaymentAction(
direction,
amount: parsed.data.amount,
counterpartTenantId: parsed.data.counterpartTenantId,
status,
},
});
// Notify the lab when a clinic submits a payment for approval.
if (status === "pending") {
void createNotification({
tenantId: parsed.data.counterpartTenantId,
message: `Yeni ödeme bildirimi: ${parsed.data.amount.toLocaleString("tr-TR")} ${parsed.data.currency} — onayınızı bekliyor.`,
});
}
} catch (e) {
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
}
@@ -152,6 +167,118 @@ export async function recordPaymentAction(
return { ok: true };
}
/**
* Lab confirms a payment the clinic claimed to have made. The row stays
* with the clinic as the recorder (tenantId), only its status flips so
* the balance computation starts counting it.
*/
export async function confirmPaymentAction(
_prev: PaymentActionState,
formData: FormData,
): Promise<PaymentActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
if (ctx.kind !== "lab") {
return { ok: false, error: "Onayı yalnızca laboratuvar verebilir." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
id,
)) as unknown as Payment;
// Lab can only confirm payments where IT is the counterpart and the
// clinic was the recorder. Anything else is a permission error.
if (row.counterpartTenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ödeme size ait değil." };
}
if (row.status === "confirmed") {
return { ok: true };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
status: "confirmed",
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "payment",
entityId: id,
changes: { status: "confirmed" },
});
void createNotification({
tenantId: row.tenantId,
message: `Ödemeniz onaylandı: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
}
revalidatePath("/finance");
return { ok: true };
}
export async function rejectPaymentAction(
_prev: PaymentActionState,
formData: FormData,
): Promise<PaymentActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
if (ctx.kind !== "lab") {
return { ok: false, error: "Reddi yalnızca laboratuvar verebilir." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
id,
)) as unknown as Payment;
if (row.counterpartTenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ödeme size ait değil." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
status: "rejected",
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "payment",
entityId: id,
changes: { status: "rejected" },
});
void createNotification({
tenantId: row.tenantId,
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
export async function deletePaymentAction(
_prev: PaymentActionState,
formData: FormData,
+65 -8
View File
@@ -12,6 +12,7 @@ import {
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
/** Payments this tenant recorded itself. */
export async function listPayments(tenantId: string): Promise<Payment[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
@@ -26,32 +27,64 @@ export async function listPayments(tenantId: string): Promise<Payment[]> {
return toPlain(result.rows as unknown as Payment[]);
}
/** Payments the counterpart recorded that involve this tenant. */
export async function listIncomingPayments(tenantId: string): Promise<Payment[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.payments,
queries: [
Query.equal("counterpartTenantId", 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 */
/** sum of *confirmed* payments — whoever recorded them */
paid: number;
/** invoiced - paid; positive means money is still owed to this tenant */
open: number;
/** most recent payment date if any, useful for sorting */
/** most recent confirmed 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.
* For a given payment row, figure out whether it represents money flowing
* *toward* `selfTenantId` from a given counterpart. Same physical payment
* looks like inflow from one side and outflow from the other — we
* normalise both shapes into 'who is the counterpart?' here.
*/
function inflowFor(
p: Payment,
selfTenantId: string,
kind: TenantKind,
): { counterpartTenantId: string } | null {
const inflowDir = kind === "lab" ? "inflow" : "outflow";
const outflowDir = kind === "lab" ? "outflow" : "inflow";
if (p.tenantId === selfTenantId && p.direction === inflowDir) {
return { counterpartTenantId: p.counterpartTenantId };
}
if (p.counterpartTenantId === selfTenantId && p.direction === outflowDir) {
return { counterpartTenantId: p.tenantId };
}
return null;
}
export function computeBalancesByCounterpart(args: {
kind: TenantKind;
selfTenantId: string;
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 => {
@@ -75,8 +108,11 @@ export function computeBalancesByCounterpart(args: {
row.invoiced += e.amount;
}
for (const p of args.payments) {
if (p.direction !== paymentDirection) continue;
const row = ensure(p.counterpartTenantId, p.currency);
// Only confirmed payments count toward the open balance.
if (p.status && p.status !== "confirmed") continue;
const mapped = inflowFor(p, args.selfTenantId, args.kind);
if (!mapped) continue;
const row = ensure(mapped.counterpartTenantId, p.currency);
row.paid += p.amount;
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
row.lastPaymentAt = p.paymentDate;
@@ -87,3 +123,24 @@ export function computeBalancesByCounterpart(args: {
}
return Array.from(acc.values()).sort((a, b) => b.open - a.open);
}
/**
* Pending payments that the *other* side recorded and are waiting on this
* tenant's confirmation. Only applicable on the lab side in our current
* model (clinics record, labs confirm), but the query is symmetrical.
*/
export function filterPendingForConfirmation(
payments: Payment[],
selfTenantId: string,
kind: TenantKind,
): Payment[] {
const expectedDirection = kind === "lab" ? "outflow" : "inflow";
return payments
.filter(
(p) =>
p.status === "pending" &&
p.counterpartTenantId === selfTenantId &&
p.direction === expectedDirection,
)
.sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1));
}
+2
View File
@@ -176,6 +176,7 @@ export interface FinanceEntry extends Row {
}
export type PaymentDirection = "inflow" | "outflow";
export type PaymentStatus = "pending" | "confirmed" | "rejected";
export interface Payment extends Row {
tenantId: string;
@@ -186,6 +187,7 @@ export interface Payment extends Row {
paymentDate: string;
method?: string;
notes?: string;
status?: PaymentStatus;
recordedBy: string;
}