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:
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Check, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
confirmPaymentAction,
|
||||||
|
rejectPaymentAction,
|
||||||
|
} from "@/lib/appwrite/payment-actions";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHOD_LABELS,
|
||||||
|
initialPaymentActionState,
|
||||||
|
} from "@/lib/appwrite/payment-types";
|
||||||
|
import type { Payment } from "@/lib/appwrite/schema";
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingPaymentsCard({
|
||||||
|
rows,
|
||||||
|
counterpartNames,
|
||||||
|
}: {
|
||||||
|
rows: Payment[];
|
||||||
|
counterpartNames: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-300/50 dark:border-amber-500/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Onay Bekleyen Ödemeler</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Klinikler aşağıdaki ödemeleri yaptıklarını bildirdi. Onayladığınızda
|
||||||
|
açık bakiyeden düşülür.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<PendingRow
|
||||||
|
key={p.$id}
|
||||||
|
payment={p}
|
||||||
|
counterpartName={counterpartNames[p.tenantId] ?? "—"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingRow({
|
||||||
|
payment,
|
||||||
|
counterpartName,
|
||||||
|
}: {
|
||||||
|
payment: Payment;
|
||||||
|
counterpartName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [confirmState, confirmAction, confirmPending] = useActionState(
|
||||||
|
confirmPaymentAction,
|
||||||
|
initialPaymentActionState,
|
||||||
|
);
|
||||||
|
const [rejectState, rejectAction, rejectPending] = useActionState(
|
||||||
|
rejectPaymentAction,
|
||||||
|
initialPaymentActionState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirmState.ok) {
|
||||||
|
toast.success("Ödeme onaylandı.");
|
||||||
|
router.refresh();
|
||||||
|
} else if (confirmState.error) {
|
||||||
|
toast.error(confirmState.error);
|
||||||
|
}
|
||||||
|
}, [confirmState, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rejectState.ok) {
|
||||||
|
toast.success("Ödeme reddedildi.");
|
||||||
|
router.refresh();
|
||||||
|
} else if (rejectState.error) {
|
||||||
|
toast.error(rejectState.error);
|
||||||
|
}
|
||||||
|
}, [rejectState, router]);
|
||||||
|
|
||||||
|
const busy = confirmPending || rejectPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-center gap-3 px-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{counterpartName}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{dateFormatter.format(new Date(payment.paymentDate))}
|
||||||
|
{payment.method && (
|
||||||
|
<> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method}</>
|
||||||
|
)}
|
||||||
|
{payment.notes && <> · {payment.notes}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-base font-semibold tabular-nums">
|
||||||
|
{formatMoney(payment.amount, payment.currency)}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
|
||||||
|
Onay bekliyor
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<form action={confirmAction}>
|
||||||
|
<input type="hidden" name="id" value={payment.$id} />
|
||||||
|
<Button type="submit" size="sm" disabled={busy}>
|
||||||
|
{confirmPending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||||
|
Onayla
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form action={rejectAction}>
|
||||||
|
<input type="hidden" name="id" value={payment.$id} />
|
||||||
|
<Button type="submit" size="sm" variant="outline" disabled={busy}>
|
||||||
|
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
|
||||||
|
Reddet
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
|
|||||||
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
|
||||||
import {
|
import {
|
||||||
computeBalancesByCounterpart,
|
computeBalancesByCounterpart,
|
||||||
|
filterPendingForConfirmation,
|
||||||
|
listIncomingPayments,
|
||||||
listPayments,
|
listPayments,
|
||||||
} from "@/lib/appwrite/payment-queries";
|
} 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 { BalancesCard } from "./components/balances-card";
|
||||||
import { FinanceTable } from "./components/finance-table";
|
import { FinanceTable } from "./components/finance-table";
|
||||||
|
import { PendingPaymentsCard } from "./components/pending-payments-card";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "DLS — Finans",
|
title: "DLS — Finans",
|
||||||
@@ -33,26 +36,46 @@ export default async function FinancePage() {
|
|||||||
if (!ctx.kind) redirect("/onboarding");
|
if (!ctx.kind) redirect("/onboarding");
|
||||||
const kind = ctx.kind;
|
const kind = ctx.kind;
|
||||||
|
|
||||||
const [entries, payments, connections] = await Promise.all([
|
const [entries, ownPayments, incomingPayments, connections] = await Promise.all([
|
||||||
listFinanceEntries(ctx.tenantId),
|
listFinanceEntries(ctx.tenantId),
|
||||||
listPayments(ctx.tenantId),
|
listPayments(ctx.tenantId),
|
||||||
|
listIncomingPayments(ctx.tenantId),
|
||||||
listApprovedConnections(ctx.tenantId),
|
listApprovedConnections(ctx.tenantId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Same physical payment can show up in both lists for the same tenant in
|
||||||
|
// pathological cases; dedupe by $id to be safe.
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const payments = [...ownPayments, ...incomingPayments].filter((p) =>
|
||||||
|
seenIds.has(p.$id) ? false : (seenIds.add(p.$id), true),
|
||||||
|
);
|
||||||
|
|
||||||
const stats = summarizeFinance(entries);
|
const stats = summarizeFinance(entries);
|
||||||
const isLab = kind === "lab";
|
const isLab = kind === "lab";
|
||||||
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
|
||||||
|
|
||||||
const balances = computeBalancesByCounterpart({ kind, entries, payments });
|
const balances = computeBalancesByCounterpart({
|
||||||
|
kind,
|
||||||
|
selfTenantId: ctx.tenantId,
|
||||||
|
entries,
|
||||||
|
payments,
|
||||||
|
});
|
||||||
|
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
|
||||||
|
|
||||||
const counterpartNames: Record<string, string> = {};
|
const counterpartNames: Record<string, string> = {};
|
||||||
for (const c of connections) {
|
for (const c of connections) {
|
||||||
const id = isLab ? c.clinicTenantId : c.labTenantId;
|
const id = isLab ? c.clinicTenantId : c.labTenantId;
|
||||||
counterpartNames[id] = c.counterpart?.companyName ?? "—";
|
counterpartNames[id] = c.counterpart?.companyName ?? "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPaid = payments
|
const totalPaid = payments.reduce((sum, p) => {
|
||||||
.filter((p) => p.direction === (isLab ? "inflow" : "outflow"))
|
if (p.status && p.status !== "confirmed") return sum;
|
||||||
.reduce((sum, p) => sum + p.amount, 0);
|
const ownInflow = p.tenantId === ctx.tenantId && p.direction === (isLab ? "inflow" : "outflow");
|
||||||
|
const incoming =
|
||||||
|
p.counterpartTenantId === ctx.tenantId &&
|
||||||
|
p.direction === (isLab ? "outflow" : "inflow");
|
||||||
|
return sum + (ownInflow || incoming ? p.amount : 0);
|
||||||
|
}, 0);
|
||||||
const totalOpen = balances.reduce(
|
const totalOpen = balances.reduce(
|
||||||
(sum, b) => sum + (b.open > 0 ? b.open : 0),
|
(sum, b) => sum + (b.open > 0 ? b.open : 0),
|
||||||
0,
|
0,
|
||||||
@@ -92,6 +115,8 @@ export default async function FinancePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
|
||||||
|
|
||||||
<BalancesCard
|
<BalancesCard
|
||||||
balances={balances}
|
balances={balances}
|
||||||
counterpartNames={counterpartNames}
|
counterpartNames={counterpartNames}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { logAudit } from "./audit";
|
import { logAudit } from "./audit";
|
||||||
|
import { createNotification } from "./notification-helpers";
|
||||||
import {
|
import {
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
TABLES,
|
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 {
|
try {
|
||||||
const created = await tablesDB.createRow(
|
const created = await tablesDB.createRow(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
@@ -128,6 +134,7 @@ export async function recordPaymentAction(
|
|||||||
paymentDate: parsed.data.paymentDate,
|
paymentDate: parsed.data.paymentDate,
|
||||||
method: parsed.data.method,
|
method: parsed.data.method,
|
||||||
notes: parsed.data.notes,
|
notes: parsed.data.notes,
|
||||||
|
status,
|
||||||
recordedBy: ctx.user.id,
|
recordedBy: ctx.user.id,
|
||||||
},
|
},
|
||||||
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
|
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
|
||||||
@@ -142,8 +149,16 @@ export async function recordPaymentAction(
|
|||||||
direction,
|
direction,
|
||||||
amount: parsed.data.amount,
|
amount: parsed.data.amount,
|
||||||
counterpartTenantId: parsed.data.counterpartTenantId,
|
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) {
|
} catch (e) {
|
||||||
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
|
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
|
||||||
}
|
}
|
||||||
@@ -152,6 +167,118 @@ export async function recordPaymentAction(
|
|||||||
return { ok: true };
|
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(
|
export async function deletePaymentAction(
|
||||||
_prev: PaymentActionState,
|
_prev: PaymentActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { createAdminClient } from "./server";
|
import { createAdminClient } from "./server";
|
||||||
import { toPlain } from "./serialize";
|
import { toPlain } from "./serialize";
|
||||||
|
|
||||||
|
/** Payments this tenant recorded itself. */
|
||||||
export async function listPayments(tenantId: string): Promise<Payment[]> {
|
export async function listPayments(tenantId: string): Promise<Payment[]> {
|
||||||
const { tablesDB } = createAdminClient();
|
const { tablesDB } = createAdminClient();
|
||||||
const result = await tablesDB.listRows({
|
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[]);
|
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 = {
|
export type CounterpartBalance = {
|
||||||
counterpartTenantId: string;
|
counterpartTenantId: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
/** sum of receivables (lab) or debts (clinic) from finance_entries */
|
/** sum of receivables (lab) or debts (clinic) from finance_entries */
|
||||||
invoiced: number;
|
invoiced: number;
|
||||||
/** sum of payments — inflow for lab, outflow for clinic */
|
/** sum of *confirmed* payments — whoever recorded them */
|
||||||
paid: number;
|
paid: number;
|
||||||
/** invoiced - paid; positive means money is still owed to this tenant */
|
/** invoiced - paid; positive means money is still owed to this tenant */
|
||||||
open: number;
|
open: number;
|
||||||
/** most recent payment date if any, useful for sorting */
|
/** most recent confirmed payment date if any, useful for sorting */
|
||||||
lastPaymentAt?: string;
|
lastPaymentAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the open balance with each counterpart in a single pass over the
|
* For a given payment row, figure out whether it represents money flowing
|
||||||
* already-loaded finance entries and payments. Lab side groups receivables
|
* *toward* `selfTenantId` from a given counterpart. Same physical payment
|
||||||
* and inflows by clinic; clinic side groups debts and outflows by lab.
|
* 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: {
|
export function computeBalancesByCounterpart(args: {
|
||||||
kind: TenantKind;
|
kind: TenantKind;
|
||||||
|
selfTenantId: string;
|
||||||
entries: FinanceEntry[];
|
entries: FinanceEntry[];
|
||||||
payments: Payment[];
|
payments: Payment[];
|
||||||
}): CounterpartBalance[] {
|
}): CounterpartBalance[] {
|
||||||
const isLab = args.kind === "lab";
|
const isLab = args.kind === "lab";
|
||||||
const invoiceType = isLab ? "receivable" : "debt";
|
const invoiceType = isLab ? "receivable" : "debt";
|
||||||
const paymentDirection = isLab ? "inflow" : "outflow";
|
|
||||||
|
|
||||||
const acc = new Map<string, CounterpartBalance>();
|
const acc = new Map<string, CounterpartBalance>();
|
||||||
const ensure = (id: string, currency: string): CounterpartBalance => {
|
const ensure = (id: string, currency: string): CounterpartBalance => {
|
||||||
@@ -75,8 +108,11 @@ export function computeBalancesByCounterpart(args: {
|
|||||||
row.invoiced += e.amount;
|
row.invoiced += e.amount;
|
||||||
}
|
}
|
||||||
for (const p of args.payments) {
|
for (const p of args.payments) {
|
||||||
if (p.direction !== paymentDirection) continue;
|
// Only confirmed payments count toward the open balance.
|
||||||
const row = ensure(p.counterpartTenantId, p.currency);
|
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;
|
row.paid += p.amount;
|
||||||
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
|
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
|
||||||
row.lastPaymentAt = p.paymentDate;
|
row.lastPaymentAt = p.paymentDate;
|
||||||
@@ -87,3 +123,24 @@ export function computeBalancesByCounterpart(args: {
|
|||||||
}
|
}
|
||||||
return Array.from(acc.values()).sort((a, b) => b.open - a.open);
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export interface FinanceEntry extends Row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentDirection = "inflow" | "outflow";
|
export type PaymentDirection = "inflow" | "outflow";
|
||||||
|
export type PaymentStatus = "pending" | "confirmed" | "rejected";
|
||||||
|
|
||||||
export interface Payment extends Row {
|
export interface Payment extends Row {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@@ -186,6 +187,7 @@ export interface Payment extends Row {
|
|||||||
paymentDate: string;
|
paymentDate: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
status?: PaymentStatus;
|
||||||
recordedBy: string;
|
recordedBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user