feat(invoices): auto income entry on 'paid' status
Marking an invoice as paid now creates a finance_entry (type=income) for
the customer with amount = invoice.total, linked via invoiceId. Reverting
status removes the entry. Idempotent: re-saving while already paid keeps
the existing entry (resyncs amount if invoice total changed in the
meantime).
- syncPaymentEntry(tenantId, userId, invoice) helper:
* status === 'paid': create entry if none exists; otherwise update
amount to match current invoice.total.
* status !== 'paid': delete any income entries linked to the invoice.
* Best-effort — failures are swallowed so the invoice mutation always
succeeds even if Appwrite hiccups on the finance write.
* Each create/delete writes an audit row tagged auto: 'invoice_paid' /
'invoice_unpaid' so we can trace later.
- updateInvoiceAction now calls syncPaymentEntry after persisting.
- recomputeTotals (run on every item add/update/delete) also re-syncs
the linked entry's amount when the invoice is currently paid.
- deleteInvoiceAction now cascade-deletes any linked finance_entries in
addition to items.
- /invoices and /invoices/[id] both revalidate /finance after writes.
UI:
- Invoice form shows a hint under the status select explaining the
finance side effect.
- Finance table tags rows with a 'Faturadan' badge when invoiceId is
set, so users can tell auto-generated entries apart from manual ones.
This commit is contained in:
@@ -191,14 +191,18 @@ export function FinanceClient({ entries, customers }: Props) {
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Açıklama",
|
||||
cell: ({ row }) =>
|
||||
row.original.description ? (
|
||||
<span className="text-muted-foreground line-clamp-1 max-w-[260px] text-sm">
|
||||
{row.original.description}
|
||||
cell: ({ row }) => (
|
||||
<div className="flex max-w-[300px] items-center gap-2">
|
||||
<span className="text-muted-foreground line-clamp-1 text-sm">
|
||||
{row.original.description || "—"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
{row.original.invoiceId && (
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
Faturadan
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FinanceRow = {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
paymentMethod: PaymentMethod;
|
||||
invoiceId: string;
|
||||
};
|
||||
|
||||
export type Customer = { id: string; name: string };
|
||||
|
||||
@@ -45,6 +45,7 @@ export default async function FinancePage() {
|
||||
customerId: e.customerId ?? "",
|
||||
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
|
||||
paymentMethod: e.paymentMethod ?? "",
|
||||
invoiceId: e.invoiceId ?? "",
|
||||
}))}
|
||||
customers={customers.map((c) => ({ id: c.$id, name: c.name }))}
|
||||
/>
|
||||
|
||||
@@ -144,6 +144,10 @@ export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Pro
|
||||
<SelectItem value="cancelled">İptal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
“Ödendi” seçildiğinde finans modülüne otomatik gelir kaydı düşer.
|
||||
Durum geri alınırsa kayıt silinir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
||||
@@ -70,9 +70,97 @@ async function nextInvoiceNumber(
|
||||
return { number, settingsId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect invoice payment status into finance_entries.
|
||||
* - status === "paid": ensure exactly one income entry exists for this invoice.
|
||||
* - otherwise: remove any income entries linked to this invoice (auto-generated).
|
||||
*
|
||||
* Best-effort. Failures here must not break the invoice mutation; we log the
|
||||
* error to audit but do not throw.
|
||||
*/
|
||||
async function syncPaymentEntry(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const linked = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", tenantId),
|
||||
Query.equal("invoiceId", invoice.$id),
|
||||
Query.equal("type", "income"),
|
||||
Query.limit(10),
|
||||
],
|
||||
});
|
||||
|
||||
if (invoice.status === "paid") {
|
||||
if (linked.rows.length === 0) {
|
||||
const row = await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.financeEntries,
|
||||
ID.unique(),
|
||||
{
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
type: "income",
|
||||
amount: Number((invoice.total ?? 0).toFixed(2)),
|
||||
date: new Date().toISOString(),
|
||||
description: `Fatura ${invoice.number} tahsilatı`,
|
||||
customerId: invoice.customerId,
|
||||
invoiceId: invoice.$id,
|
||||
},
|
||||
[
|
||||
Permission.read(Role.team(tenantId)),
|
||||
Permission.update(Role.team(tenantId)),
|
||||
Permission.delete(Role.team(tenantId, "owner")),
|
||||
Permission.delete(Role.team(tenantId, "admin")),
|
||||
],
|
||||
);
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId,
|
||||
action: "create",
|
||||
entityType: "finance_entry",
|
||||
entityId: row.$id,
|
||||
changes: { auto: "invoice_paid", invoiceId: invoice.$id, amount: invoice.total },
|
||||
});
|
||||
} else {
|
||||
// Keep the first; resync amount in case the invoice total changed
|
||||
const first = linked.rows[0];
|
||||
const desiredAmount = Number((invoice.total ?? 0).toFixed(2));
|
||||
if (
|
||||
(first as unknown as { amount: number }).amount !== desiredAmount
|
||||
) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, first.$id, {
|
||||
amount: desiredAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const r of linked.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId,
|
||||
action: "delete",
|
||||
entityType: "finance_entry",
|
||||
entityId: r.$id,
|
||||
changes: { auto: "invoice_unpaid", invoiceId: invoice.$id },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; don't block the invoice update
|
||||
}
|
||||
}
|
||||
|
||||
async function recomputeTotals(
|
||||
tenantId: string,
|
||||
invoiceId: string,
|
||||
userId?: string,
|
||||
): Promise<{ subtotal: number; vatTotal: number; total: number }> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const result = await tablesDB.listRows({
|
||||
@@ -98,6 +186,23 @@ async function recomputeTotals(
|
||||
vatTotal: Number(vatTotal.toFixed(2)),
|
||||
total: Number(total.toFixed(2)),
|
||||
});
|
||||
|
||||
// If invoice is paid, keep the linked income entry's amount in sync.
|
||||
if (userId) {
|
||||
try {
|
||||
const refreshed = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
invoiceId,
|
||||
)) as unknown as Invoice;
|
||||
if (refreshed.status === "paid") {
|
||||
await syncPaymentEntry(tenantId, userId, refreshed);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
return { subtotal, vatTotal, total };
|
||||
}
|
||||
|
||||
@@ -220,12 +325,21 @@ export async function updateInvoiceAction(
|
||||
entityId: id,
|
||||
changes: data,
|
||||
});
|
||||
|
||||
// Sync payment ↔ finance entry on every save (cheap; idempotent).
|
||||
const refreshed = (await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.invoices,
|
||||
id,
|
||||
)) as unknown as Invoice;
|
||||
await syncPaymentEntry(ctx.tenantId, ctx.user.id, refreshed);
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath(`/invoices/${id}`);
|
||||
revalidatePath("/finance");
|
||||
return { ok: true, invoiceId: id };
|
||||
}
|
||||
|
||||
@@ -267,6 +381,20 @@ export async function deleteInvoiceAction(
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id);
|
||||
}
|
||||
|
||||
// Cascade-delete any auto-generated income entries linked to this invoice
|
||||
const linkedEntries = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.financeEntries,
|
||||
queries: [
|
||||
Query.equal("tenantId", ctx.tenantId),
|
||||
Query.equal("invoiceId", id),
|
||||
Query.limit(50),
|
||||
],
|
||||
});
|
||||
for (const r of linkedEntries.rows) {
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, r.$id);
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id);
|
||||
|
||||
await logAudit({
|
||||
@@ -275,13 +403,18 @@ export async function deleteInvoiceAction(
|
||||
action: "delete",
|
||||
entityType: "invoice",
|
||||
entityId: id,
|
||||
changes: { number: existing.number, items: items.rows.length },
|
||||
changes: {
|
||||
number: existing.number,
|
||||
items: items.rows.length,
|
||||
linkedFinanceEntries: linkedEntries.rows.length,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e) };
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -351,7 +484,7 @@ export async function addInvoiceItemAction(
|
||||
teamRowPermissions(ctx.tenantId),
|
||||
);
|
||||
|
||||
await recomputeTotals(ctx.tenantId, invoiceId);
|
||||
await recomputeTotals(ctx.tenantId, invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
@@ -409,7 +542,7 @@ export async function updateInvoiceItemAction(
|
||||
lineTotal: total,
|
||||
});
|
||||
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId);
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
@@ -466,7 +599,7 @@ export async function deleteInvoiceItemAction(
|
||||
}
|
||||
|
||||
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id);
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId);
|
||||
await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
|
||||
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
|
||||
Reference in New Issue
Block a user