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:
kovakmedya
2026-04-30 06:14:31 +03:00
parent d99daca3ca
commit 37777a71f9
5 changed files with 154 additions and 11 deletions
@@ -191,14 +191,18 @@ export function FinanceClient({ entries, customers }: Props) {
{ {
accessorKey: "description", accessorKey: "description",
header: "Açıklama", header: "Açıklama",
cell: ({ row }) => cell: ({ row }) => (
row.original.description ? ( <div className="flex max-w-[300px] items-center gap-2">
<span className="text-muted-foreground line-clamp-1 max-w-[260px] text-sm"> <span className="text-muted-foreground line-clamp-1 text-sm">
{row.original.description} {row.original.description || "—"}
</span> </span>
) : ( {row.original.invoiceId && (
<span className="text-muted-foreground"></span> <Badge variant="outline" className="shrink-0 text-[10px]">
), Faturadan
</Badge>
)}
</div>
),
}, },
{ {
id: "actions", id: "actions",
@@ -10,6 +10,7 @@ export type FinanceRow = {
customerId: string; customerId: string;
customerName: string; customerName: string;
paymentMethod: PaymentMethod; paymentMethod: PaymentMethod;
invoiceId: string;
}; };
export type Customer = { id: string; name: string }; export type Customer = { id: string; name: string };
+1
View File
@@ -45,6 +45,7 @@ export default async function FinancePage() {
customerId: e.customerId ?? "", customerId: e.customerId ?? "",
customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "", customerName: e.customerId ? customerMap.get(e.customerId) ?? "" : "",
paymentMethod: e.paymentMethod ?? "", paymentMethod: e.paymentMethod ?? "",
invoiceId: e.invoiceId ?? "",
}))} }))}
customers={customers.map((c) => ({ id: c.$id, name: c.name }))} 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> <SelectItem value="cancelled">İptal</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs">
&ldquo;Ödendi&rdquo; seçildiğinde finans modülüne otomatik gelir kaydı düşer.
Durum geri alınırsa kayıt silinir.
</p>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
+137 -4
View File
@@ -70,9 +70,97 @@ async function nextInvoiceNumber(
return { number, settingsId: null }; 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( async function recomputeTotals(
tenantId: string, tenantId: string,
invoiceId: string, invoiceId: string,
userId?: string,
): Promise<{ subtotal: number; vatTotal: number; total: number }> { ): Promise<{ subtotal: number; vatTotal: number; total: number }> {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({ const result = await tablesDB.listRows({
@@ -98,6 +186,23 @@ async function recomputeTotals(
vatTotal: Number(vatTotal.toFixed(2)), vatTotal: Number(vatTotal.toFixed(2)),
total: Number(total.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 }; return { subtotal, vatTotal, total };
} }
@@ -220,12 +325,21 @@ export async function updateInvoiceAction(
entityId: id, entityId: id,
changes: data, 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) { } catch (e) {
return { ok: false, error: appwriteError(e) }; return { ok: false, error: appwriteError(e) };
} }
revalidatePath("/invoices"); revalidatePath("/invoices");
revalidatePath(`/invoices/${id}`); revalidatePath(`/invoices/${id}`);
revalidatePath("/finance");
return { ok: true, invoiceId: id }; return { ok: true, invoiceId: id };
} }
@@ -267,6 +381,20 @@ export async function deleteInvoiceAction(
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, it.$id); 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 tablesDB.deleteRow(DATABASE_ID, TABLES.invoices, id);
await logAudit({ await logAudit({
@@ -275,13 +403,18 @@ export async function deleteInvoiceAction(
action: "delete", action: "delete",
entityType: "invoice", entityType: "invoice",
entityId: id, entityId: id,
changes: { number: existing.number, items: items.rows.length }, changes: {
number: existing.number,
items: items.rows.length,
linkedFinanceEntries: linkedEntries.rows.length,
},
}); });
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e) }; return { ok: false, error: appwriteError(e) };
} }
revalidatePath("/invoices"); revalidatePath("/invoices");
revalidatePath("/finance");
return { ok: true }; return { ok: true };
} }
@@ -351,7 +484,7 @@ export async function addInvoiceItemAction(
teamRowPermissions(ctx.tenantId), teamRowPermissions(ctx.tenantId),
); );
await recomputeTotals(ctx.tenantId, invoiceId); await recomputeTotals(ctx.tenantId, invoiceId, ctx.user.id);
await logAudit({ await logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
@@ -409,7 +542,7 @@ export async function updateInvoiceItemAction(
lineTotal: total, lineTotal: total,
}); });
await recomputeTotals(ctx.tenantId, existing.invoiceId); await recomputeTotals(ctx.tenantId, existing.invoiceId, ctx.user.id);
await logAudit({ await logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
@@ -466,7 +599,7 @@ export async function deleteInvoiceItemAction(
} }
await tablesDB.deleteRow(DATABASE_ID, TABLES.invoiceItems, id); 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({ await logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,