feat(banking A): bank accounts module + finance integration

First of 3-step banking expansion. Banks tracked separately from
customer/supplier debts so we can compute real cash position later.

Schema:
- New bank_accounts table: bankName, accountName, iban, openingBalance,
  notes, archived. Indexes on (tenantId, archived).
- New column finance_entries.bankAccountId (FK, optional). Index on
  (tenantId, bankAccountId).
- schema.ts: TABLES.bankAccounts, BankAccount type, FinanceEntry gains
  bankAccountId.

Server side:
- lib/validation/bank-accounts.ts (Zod): IBAN normalized to upper-case
  no-spaces; openingBalance defaults to 0.
- lib/appwrite/bank-account-actions.ts: create/update/archive(toggle)/
  delete with audit. Delete refuses if any finance_entry still references
  the account; archive toggle replaces it for safe disable.
- lib/appwrite/bank-account-queries.ts:
  * listBankAccounts
  * getBankAccountBalances — computes opening + Σ(income) − Σ(expense)
    per account by scanning up to 5000 entries with bankAccountId set.
    Pure cash flow; debt/receivable don't move balance.
  * listEntriesForAccount

UI:
- /finance/banks server page renders BanksClient with computed balances.
- BanksClient: card grid for active accounts, collapsed details for
  archived. Sum card on top showing total active balance (color-coded by
  sign). Each card shows bank, account name, formatted IBAN, current
  balance + opening (if drifted). Dropdown: Düzenle / Arşivle / Sil.
- BankFormSheet: bank/account/IBAN/openingBalance/notes form.
- Finance form gets a bank-account Select (sentinel-stripped). Existing
  finance entries get a 'bankAccountLabel' subtitle in their row.

Sidebar: Finans group expanded with Bankalar submenu (Banka hesapları
/ Krediler / Kredi kartları). The latter two land in B and C.
This commit is contained in:
kovakmedya
2026-04-30 07:22:51 +03:00
parent c848531326
commit 7b6be623ae
16 changed files with 972 additions and 19 deletions
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type BankAccount,
type FinanceEntry,
} from "./schema";
export async function listBankAccounts(tenantId: string): Promise<BankAccount[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankAccounts,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("bankName"),
Query.limit(200),
],
});
return result.rows as unknown as BankAccount[];
} catch {
return [];
}
}
/**
* Computes a current balance for each account: openingBalance + Σ(income/receivable) Σ(expense/debt).
*/
export async function getBankAccountBalances(
tenantId: string,
): Promise<Map<string, number>> {
const balances = new Map<string, number>();
try {
const { tablesDB } = createAdminClient();
const accounts = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.bankAccounts,
queries: [Query.equal("tenantId", tenantId), Query.limit(200)],
});
for (const a of accounts.rows as unknown as BankAccount[]) {
balances.set(a.$id, a.openingBalance ?? 0);
}
const entries = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.isNotNull("bankAccountId"),
Query.limit(5000),
],
});
for (const e of entries.rows as unknown as FinanceEntry[]) {
if (!e.bankAccountId) continue;
const cur = balances.get(e.bankAccountId);
if (cur === undefined) continue;
if (e.type === "income") balances.set(e.bankAccountId, cur + e.amount);
else if (e.type === "expense") balances.set(e.bankAccountId, cur - e.amount);
// debt/receivable don't affect cash balance
}
} catch {
/* ignore */
}
return balances;
}
export async function listEntriesForAccount(
tenantId: string,
bankAccountId: string,
limit = 25,
): Promise<FinanceEntry[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("bankAccountId", bankAccountId),
Query.orderDesc("date"),
Query.limit(limit),
],
});
return result.rows as unknown as FinanceEntry[];
} catch {
return [];
}
}