From 37777a71f9b8b5411b33b69be63eb634d5b890e4 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 06:14:31 +0300 Subject: [PATCH] feat(invoices): auto income entry on 'paid' status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../finance/components/finance-client.tsx | 18 ++- .../(dashboard)/finance/components/types.ts | 1 + src/app/(dashboard)/finance/page.tsx | 1 + .../components/invoice-form-sheet.tsx | 4 + src/lib/appwrite/invoice-actions.ts | 141 +++++++++++++++++- 5 files changed, 154 insertions(+), 11 deletions(-) diff --git a/src/app/(dashboard)/finance/components/finance-client.tsx b/src/app/(dashboard)/finance/components/finance-client.tsx index e622c88..377bf2a 100644 --- a/src/app/(dashboard)/finance/components/finance-client.tsx +++ b/src/app/(dashboard)/finance/components/finance-client.tsx @@ -191,14 +191,18 @@ export function FinanceClient({ entries, customers }: Props) { { accessorKey: "description", header: "Açıklama", - cell: ({ row }) => - row.original.description ? ( - - {row.original.description} + cell: ({ row }) => ( +
+ + {row.original.description || "—"} - ) : ( - - ), + {row.original.invoiceId && ( + + Faturadan + + )} +
+ ), }, { id: "actions", diff --git a/src/app/(dashboard)/finance/components/types.ts b/src/app/(dashboard)/finance/components/types.ts index 9ef7567..f8bdf6f 100644 --- a/src/app/(dashboard)/finance/components/types.ts +++ b/src/app/(dashboard)/finance/components/types.ts @@ -10,6 +10,7 @@ export type FinanceRow = { customerId: string; customerName: string; paymentMethod: PaymentMethod; + invoiceId: string; }; export type Customer = { id: string; name: string }; diff --git a/src/app/(dashboard)/finance/page.tsx b/src/app/(dashboard)/finance/page.tsx index b3a2ec5..9811549 100644 --- a/src/app/(dashboard)/finance/page.tsx +++ b/src/app/(dashboard)/finance/page.tsx @@ -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 }))} /> diff --git a/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx index e09fed6..50455e4 100644 --- a/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx +++ b/src/app/(dashboard)/invoices/components/invoice-form-sheet.tsx @@ -144,6 +144,10 @@ export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Pro İptal +

+ “Ödendi” seçildiğinde finans modülüne otomatik gelir kaydı düşer. + Durum geri alınırsa kayıt silinir. +

diff --git a/src/lib/appwrite/invoice-actions.ts b/src/lib/appwrite/invoice-actions.ts index 5071758..bdffc35 100644 --- a/src/lib/appwrite/invoice-actions.ts +++ b/src/lib/appwrite/invoice-actions.ts @@ -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 { + 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,