diff --git a/Dockerfile b/Dockerfile index 6e817df..2382106 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,6 @@ COPY --from=builder /app/scripts/run-migrations.ts scripts/run-migrations.ts USER finaflow EXPOSE 3000 -# HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ -# CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 0 CMD ["sh", "-c", "npx tsx scripts/run-migrations.ts && node dist/boot.js"] diff --git a/api/__tests__/business-reset.test.ts b/api/__tests__/business-reset.test.ts index 84bfdcb..a78d775 100644 --- a/api/__tests__/business-reset.test.ts +++ b/api/__tests__/business-reset.test.ts @@ -1,20 +1,54 @@ -// ABOUTME: Verifies business transaction reset clears newly added accounting records and resets balances. -// ABOUTME: Protects the Businesses reset action from leaving journal, ledger, and system-managed account residue behind. +// ABOUTME: Comprehensive test suite for the enhanced business transaction reset functionality. +// ABOUTME: Verifies data clearing, record preservation, referential integrity, and audit logging. import { afterEach, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; +import { eq, and, isNull, sql, inArray } from "drizzle-orm"; import { getTestDb } from "../test/db"; import { accounts, + bills, + billItems, + billPayments, businesses, + dailySales, + dailySalePayments, + expenses, + expenseItems, journalEntries, journalLines, ledgerEntries, locations, + mpesaTransactions, + notifications, + payrollAdvances, + payrollEntries, + payrollPeriods, + suppliers, userBusinesses, users, + expenseCategories, + employees, + auditLog, + budgets, + purchaseOrders, + purchaseOrderItems, + recurringBillTemplates, + dailyMpesaLedger, } from "@db/schema"; -import { resetBusinessTransactions } from "../lib/business-reset"; +import { + resetBusinessTransactions, + validatePreReset, + createResetSnapshot, +} from "../lib/business-reset"; + +// ─── Test Utilities ────────────────────────────────────────────────────────── + +// Helper to handle the union return type of drizzle .returning() +async function firstRow(promise: Promise): Promise { + const result = await promise; + const rows = Array.isArray(result) ? result : []; + return rows[0] as T; +} type SeededContext = { accountId: string; @@ -24,27 +58,36 @@ type SeededContext = { operationalAccount: { id: number }; systemAccount: { id: number }; journalEntry: { id: number }; + employee: { id: number }; + supplier: { id: number }; + expense: { id: number }; + expenseItem: { id: number }; + sale: { id: number }; + bill: { id: number }; + category: { id: number }; + payrollPeriod: { id: number }; + purchaseOrder: { id: number }; }; async function seedResetContext(seed: string): Promise { const db = getTestDb(); const accountId = `RESET-${seed}`; - const [business] = await db.insert(businesses).values({ + const business = await firstRow(db.insert(businesses).values({ accountId, name: `Reset ${seed}`, slug: `reset-${seed.toLowerCase()}`, plan: "pro", isActive: true, - } as any).returning(); + } as any).returning()); - const [owner] = await db.insert(users).values({ + const owner = await firstRow(db.insert(users).values({ username: `owner-${seed.toLowerCase()}`, role: "owner", isActive: true, currentBusinessId: business.id, accountId, - } as any).returning(); + } as any).returning()); await db.insert(userBusinesses).values({ userId: owner.id, @@ -53,17 +96,27 @@ async function seedResetContext(seed: string): Promise { isActive: true, } as any); - const locRows = await db.insert(locations).values({ + const location = await firstRow(db.insert(locations).values({ businessId: business.id, name: `Main ${seed}`, slug: `main-${seed.toLowerCase()}`, isActive: true, nextBillNumber: 19, nextExpenseNumber: 27, - } as any).returning(); - const [location] = locRows as any[]; + } as any).returning()); + + // Create expense category + const category = await firstRow(db.insert(expenseCategories).values({ + businessId: business.id, + locationId: location.id, + name: `Test Category ${seed}`, + color: "#C73E1D", + defaultAccountId: 0, + isActive: true, + } as any).returning()); - const opRows = await db.insert(accounts).values({ + // Create operational (user) account + const opAccount = await firstRow(db.insert(accounts).values({ businessId: business.id, locationId: location.id, name: "Cash Drawer", @@ -71,10 +124,10 @@ async function seedResetContext(seed: string): Promise { currentBalance: "450.00", openingBalance: "100.00", isActive: true, - } as any).returning(); - const [operationalAccount] = opRows as any[]; + } as any).returning()); - const sysRows = await db.insert(accounts).values({ + // Create system account + const sysAccount = await firstRow(db.insert(accounts).values({ businessId: business.id, locationId: null, name: "Expense Clearing", @@ -86,29 +139,53 @@ async function seedResetContext(seed: string): Promise { systemKey: "expense:operating_expense", isSystemGenerated: true, isActive: true, - } as any).returning(); - const [systemAccount] = sysRows as any[]; + } as any).returning()); + + // Fix category defaultAccountId + await db.update(expenseCategories) + .set({ defaultAccountId: sysAccount.id }) + .where(eq(expenseCategories.id, category.id)); + + // Create supplier + const supplier = await firstRow(db.insert(suppliers).values({ + businessId: business.id, + name: `Supplier ${seed}`, + currentBalance: "5000.00", + totalBilled: "10000.00", + totalPaid: "5000.00", + } as any).returning()); + + // Create employee + const employee = await firstRow(db.insert(employees).values({ + locationId: location.id, + fullName: `Employee ${seed}`, + phone: "0712345678", + salaryType: "monthly", + basicSalary: "50000.00", + employmentDate: "2026-01-01", + isActive: true, + } as any).returning()); - const entryRows = await db.insert(journalEntries).values({ + // Create journal entry + const entry = await firstRow(db.insert(journalEntries).values({ businessId: business.id, entryNumber: `JE-${seed}`, entryDate: "2026-05-16", description: "Reset me", isPosted: true, createdBy: owner.id, - } as any).returning(); - const [entry] = entryRows as any[]; + } as any).returning()); await db.insert(journalLines).values({ journalEntryId: entry.id, - accountId: systemAccount.id, + accountId: sysAccount.id, debit: "900.00", credit: "0.00", lineNumber: 1, } as any); await db.insert(ledgerEntries).values({ - accountId: systemAccount.id, + accountId: sysAccount.id, transactionType: "journal", transactionId: entry.id, entryType: "debit", @@ -118,14 +195,167 @@ async function seedResetContext(seed: string): Promise { createdBy: owner.id, } as any); + // Create daily sale + const sale = await firstRow(db.insert(dailySales).values({ + locationId: location.id, + saleDate: "2026-05-16", + netSales: "1500.00", + cashTotal: "1500.00", + enteredBy: owner.id, + } as any).returning()); + + await db.insert(dailySalePayments).values({ + dailySaleId: sale.id, + paymentMethodId: 1, + amount: "1500.00", + } as any); + + // Create expense + const expense = await firstRow(db.insert(expenses).values({ + locationId: location.id, + businessId: business.id, + categoryId: category.id, + supplierId: supplier.id, + amount: "250.00", + description: `Test expense ${seed}`, + expenseDate: "2026-05-16", + paymentMethod: "cash", + accountId: opAccount.id, + enteredBy: owner.id, + } as any).returning()); + + const expenseItem = await firstRow(db.insert(expenseItems).values({ + expenseId: expense.id, + itemName: `Item ${seed}`, + quantity: "1.000", + unitPrice: "250.00", + totalPrice: "250.00", + categoryId: category.id, + } as any).returning()); + + // Create bill + const bill = await firstRow(db.insert(bills).values({ + locationId: location.id, + businessId: business.id, + supplierId: supplier.id, + amount: "1000.00", + amountPaid: "0.00", + balanceDue: "1000.00", + description: `Test bill ${seed}`, + issueDate: "2026-05-16", + dueDate: "2026-06-15", + status: "pending", + } as any).returning()); + + await db.insert(billItems).values({ + billId: bill.id, + itemName: `Bill Item ${seed}`, + quantity: "1.000", + unitPrice: "1000.00", + totalPrice: "1000.00", + } as any); + + // Create M-PESA transaction + await db.insert(mpesaTransactions).values({ + locationId: location.id, + txnId: `MPESA-${seed}`, + txnDate: "2026-05-16", + txnType: "topup", + amount: "500.00", + description: `Test M-PESA ${seed}`, + importedBy: owner.id, + } as any); + + // Create payroll period + const payrollPeriod = await firstRow(db.insert(payrollPeriods).values({ + locationId: location.id, + periodName: `May 2026 ${seed}`, + startDate: "2026-05-01", + endDate: "2026-05-31", + paymentDate: "2026-05-25", + status: "open", + } as any).returning()); + + await db.insert(payrollEntries).values({ + periodId: payrollPeriod.id, + employeeId: employee.id, + basicPay: "50000.00", + netPay: "45000.00", + paymentMethod: "mpesa", + } as any); + + await db.insert(payrollAdvances).values({ + employeeId: employee.id, + amount: "5000.00", + balanceRemaining: "3000.00", + requestDate: "2026-05-10", + status: "approved", + } as any); + + await db.insert(budgets).values({ + locationId: location.id, + categoryId: category.id, + month: 5, + year: 2026, + amount: "5000.00", + } as any); + + const po = await firstRow(db.insert(purchaseOrders).values({ + locationId: location.id, + supplierId: supplier.id, + poNumber: `PO-${seed}`, + description: `Test PO ${seed}`, + status: "draft", + total: "2000.00", + createdBy: owner.id, + } as any).returning()); + + await db.insert(purchaseOrderItems).values({ + poId: po.id, + itemName: `PO Item ${seed}`, + quantity: "2.000", + unitPrice: "1000.00", + totalPrice: "2000.00", + } as any); + + // Create recurring bill template + await db.insert(recurringBillTemplates).values({ + locationId: location.id, + businessId: business.id, + description: `Recurring ${seed}`, + amount: "500.00", + frequency: "monthly", + nextDueDate: "2026-06-01", + isActive: true, + } as any); + + // Create notification + await db.insert(notifications).values({ + userId: owner.id, + type: "info", + title: `Test notification ${seed}`, + message: "This is a test notification", + severity: "info", + locationId: location.id, + } as any); + return { accountId, business, location, owner: { id: owner.id, role: owner.role, currentBusinessId: business.id }, - operationalAccount, - systemAccount, + operationalAccount: opAccount, + systemAccount: sysAccount, journalEntry: entry, + employee, + supplier, + expense, + expenseItem, + sale, + bill, + category, + payrollPeriod, + purchaseOrder: po, }; } @@ -134,29 +364,69 @@ async function cleanupResetContext(accountId: string) { const [business] = await db.select().from(businesses).where(eq(businesses.accountId, accountId)).limit(1); if (!business) return; + // Clean up in proper dependency order + const locRows = await db.select({ id: locations.id }).from(locations).where(eq(locations.businessId, business.id)); + const locIds = locRows.map((r) => r.id); + + if (locIds.length > 0) { + const locIdSql = sql.join(locIds.map((id) => sql`${id}`), sql`, `); + await db.delete(notifications).where(sql`${notifications.locationId} IN (${locIdSql})`); + await db.delete(dailySalePayments).where(sql`${dailySalePayments.id} > 0`); + await db.delete(dailySales).where(sql`${dailySales.locationId} IN (${locIdSql})`); + await db.delete(budgets).where(sql`${budgets.locationId} IN (${locIdSql})`); + await db.delete(purchaseOrderItems).where(sql`${purchaseOrderItems.id} > 0`); + await db.delete(purchaseOrders).where(sql`${purchaseOrders.locationId} IN (${locIdSql})`); + await db.delete(recurringBillTemplates).where(sql`${recurringBillTemplates.locationId} IN (${locIdSql})`); + await db.delete(mpesaTransactions).where(sql`${mpesaTransactions.locationId} IN (${locIdSql})`); + } + const journalRows = await db.select({ id: journalEntries.id }).from(journalEntries).where(eq(journalEntries.businessId, business.id)); - const journalIds = journalRows.map((row) => row.id); + const journalIds = journalRows.map((r) => r.id); const accountRows = await db.select({ id: accounts.id }).from(accounts).where(eq(accounts.businessId, business.id)); - const accountIds = accountRows.map((row) => row.id); + const accountIds = accountRows.map((r) => r.id); + const expenseRows = await db.select({ id: expenses.id }).from(expenses).where(eq(expenses.businessId, business.id)); + const expenseIds = expenseRows.map((r) => r.id); + const billRows = await db.select({ id: bills.id }).from(bills).where(eq(bills.businessId, business.id)); + const billIds = billRows.map((r) => r.id); + const employeeRows = await db.select({ id: employees.id }).from(employees).where(eq(employees.locationId, locIds[0])); + const employeeIds = employeeRows.map((r) => r.id); - if (accountIds.length > 0) { - await db.delete(ledgerEntries).where(eq(ledgerEntries.accountId, accountIds[0])); - for (const accountIdToDelete of accountIds.slice(1)) { - await db.delete(ledgerEntries).where(eq(ledgerEntries.accountId, accountIdToDelete)); - } + if (employeeIds.length > 0) { + await db.delete(payrollAdvances).where(inArray(payrollAdvances.employeeId, employeeIds)); + } + if (expenseIds.length > 0) { + await db.delete(expenseItems).where(inArray(expenseItems.expenseId, expenseIds)); } + if (billIds.length > 0) { + await db.delete(billItems).where(inArray(billItems.billId, billIds)); + await db.delete(billPayments).where(inArray(billPayments.billId, billIds)); + } + await db.delete(payrollEntries).where(sql`${payrollEntries.id} > 0`); + await db.delete(payrollPeriods).where(sql`${payrollPeriods.id} > 0`); - for (const journalId of journalIds) { - await db.delete(journalLines).where(eq(journalLines.journalEntryId, journalId)); + for (const jId of journalIds) { + await db.delete(journalLines).where(eq(journalLines.journalEntryId, jId)); + } + if (accountIds.length > 0) { + const acctIdSql = sql.join(accountIds.map((id) => sql`${id}`), sql`, `); + await db.delete(ledgerEntries).where(sql`${ledgerEntries.accountId} IN (${acctIdSql})`); } await db.delete(journalEntries).where(eq(journalEntries.businessId, business.id)); + await db.delete(expenses).where(eq(expenses.businessId, business.id)); + await db.delete(bills).where(eq(bills.businessId, business.id)); await db.delete(accounts).where(eq(accounts.businessId, business.id)); + await db.delete(expenseCategories).where(eq(expenseCategories.businessId, business.id)); + await db.delete(suppliers).where(eq(suppliers.businessId, business.id)); + await db.delete(employees).where(sql`${employees.id} > 0`); await db.delete(locations).where(eq(locations.businessId, business.id)); await db.delete(userBusinesses).where(eq(userBusinesses.businessId, business.id)); + await db.delete(auditLog).where(eq(auditLog.tableName, "business_reset")); await db.delete(users).where(eq(users.accountId, accountId)); await db.delete(businesses).where(eq(businesses.id, business.id)); } +// ─── Tests ─────────────────────────────────────────────────────────────────── + describe("resetBusinessTransactions", () => { const seededAccountIds: string[] = []; @@ -169,8 +439,10 @@ describe("resetBusinessTransactions", () => { } }); - it("clears journal and ledger activity and resets both operational and system-managed balances", async () => { - const seed = `${Date.now()}`; + // ── Test 1: Comprehensive reset clears all transactional data ──────────── + + it("clears all transactional data while preserving setup records", async () => { + const seed = `COMPREHENSIVE-${Date.now()}`; const ctx = await seedResetContext(seed); seededAccountIds.push(ctx.accountId); const db = getTestDb(); @@ -178,23 +450,406 @@ describe("resetBusinessTransactions", () => { const result = await resetBusinessTransactions({ db, businessId: ctx.business.id, + userId: ctx.owner.id, }); - const [savedJournalEntry] = await db.select().from(journalEntries).where(eq(journalEntries.id, ctx.journalEntry.id)).limit(1); - const [savedSystemAccount] = await db.select().from(accounts).where(eq(accounts.id, ctx.systemAccount.id)).limit(1); - const [savedOperationalAccount] = await db.select().from(accounts).where(eq(accounts.id, ctx.operationalAccount.id)).limit(1); - const [savedLocation] = await db.select().from(locations).where(eq(locations.id, ctx.location.id)).limit(1); - const deletedLedgerRows = await db.select().from(ledgerEntries).where(eq(ledgerEntries.accountId, ctx.systemAccount.id)); - expect(result.success).toBe(true); - expect(savedJournalEntry.deletedAt).not.toBeNull(); + expect(result.preserved).toContain("audit_log"); + expect(result.preserved).toContain("accounts (system)"); + expect(result.resetAt).toBeTruthy(); + + // Verify system account preserved and balance reset + const [savedSystemAccount] = await db + .select() + .from(accounts) + .where(eq(accounts.id, ctx.systemAccount.id)) + .limit(1); + expect(savedSystemAccount).toBeDefined(); + expect(savedSystemAccount.deletedAt).toBeNull(); expect(savedSystemAccount.currentBalance).toBe("0.00"); - expect(savedOperationalAccount.deletedAt).not.toBeNull(); - expect(savedOperationalAccount.isActive).toBe(false); + expect(savedSystemAccount.isActive).toBe(true); + + // Verify user account preserved with balance reset + const [savedOperationalAccount] = await db + .select() + .from(accounts) + .where(eq(accounts.id, ctx.operationalAccount.id)) + .limit(1); + expect(savedOperationalAccount.deletedAt).toBeNull(); + expect(savedOperationalAccount.isActive).toBe(true); expect(savedOperationalAccount.currentBalance).toBe("0.00"); + + // Verify journal entry hard-deleted + const journalEntriesAfter = await db + .select() + .from(journalEntries) + .where(eq(journalEntries.id, ctx.journalEntry.id)); + expect(journalEntriesAfter.length).toBe(0); + + // Verify journal lines hard-deleted + const journalLinesAfter = await db + .select() + .from(journalLines) + .where(eq(journalLines.journalEntryId, ctx.journalEntry.id)); + expect(journalLinesAfter.length).toBe(0); + + // Verify ledger entries hard-deleted + const ledgerAfter = await db + .select() + .from(ledgerEntries) + .where(eq(ledgerEntries.accountId, ctx.systemAccount.id)); + expect(ledgerAfter.length).toBe(0); + + // Verify daily sale soft-deleted + const [savedSale] = await db + .select() + .from(dailySales) + .where(eq(dailySales.id, ctx.sale.id)) + .limit(1); + expect(savedSale.deletedAt).not.toBeNull(); + + // Verify expense soft-deleted + const [savedExpense] = await db + .select() + .from(expenses) + .where(eq(expenses.id, ctx.expense.id)) + .limit(1); + expect(savedExpense.deletedAt).not.toBeNull(); + + // Verify expense items soft-deleted + const [savedExpenseItem] = await db + .select() + .from(expenseItems) + .where(eq(expenseItems.id, ctx.expenseItem.id)) + .limit(1); + expect(savedExpenseItem.deletedAt).not.toBeNull(); + + // Verify bill soft-deleted + const [savedBill] = await db + .select() + .from(bills) + .where(eq(bills.id, ctx.bill.id)) + .limit(1); + expect(savedBill.deletedAt).not.toBeNull(); + expect(savedBill.status).toBe("cancelled"); + expect(savedBill.balanceDue).toBe("0.00"); + + // Verify location counters reset + const [savedLocation] = await db + .select() + .from(locations) + .where(eq(locations.id, ctx.location.id)) + .limit(1); expect(savedLocation.nextBillNumber).toBe(1); expect(savedLocation.nextExpenseNumber).toBe(1); - expect(deletedLedgerRows.every((row) => row.deletedAt !== null)).toBe(true); - expect(result.results.user_accounts?.count).toBe(1); + + // Verify supplier balances reset + const [savedSupplier] = await db + .select() + .from(suppliers) + .where(eq(suppliers.id, ctx.supplier.id)) + .limit(1); + expect(savedSupplier.currentBalance).toBe("0.00"); + expect(savedSupplier.totalBilled).toBe("0.00"); + expect(savedSupplier.totalPaid).toBe("0.00"); + + // Verify audit log entry was written + const auditEntries = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.tableName, "business_reset"), eq(auditLog.recordId, ctx.business.id))); + expect(auditEntries.length).toBeGreaterThan(0); + expect(auditEntries[0].action).toBe("DELETE"); + }); + + // ── Test 2: Preserved records remain intact ────────────────────────────── + + it("preserves all immutable records after reset", async () => { + const seed = `PRESERVE-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + await resetBusinessTransactions({ + db, + businessId: ctx.business.id, + userId: ctx.owner.id, + }); + + // Preserved: locations (still exist) + const locationsAfter = await db + .select() + .from(locations) + .where(and(eq(locations.businessId, ctx.business.id), isNull(locations.deletedAt))); + expect(locationsAfter.length).toBeGreaterThan(0); + + // Preserved: expense_categories + const categoriesAfter = await db + .select() + .from(expenseCategories) + .where(eq(expenseCategories.id, ctx.category.id)); + expect(categoriesAfter[0].deletedAt).toBeNull(); + + // Preserved: employees (still exist, not deleted) + const employeesAfter = await db + .select() + .from(employees) + .where(eq(employees.id, ctx.employee.id)); + expect(employeesAfter[0].deletedAt).toBeNull(); + expect(employeesAfter[0].isActive).toBe(true); + + // Preserved: suppliers (still exist) + const suppliersAfter = await db + .select() + .from(suppliers) + .where(eq(suppliers.id, ctx.supplier.id)); + expect(suppliersAfter[0].deletedAt).toBeNull(); + }); + + // ── Test 3: Pre-reset validation works ─────────────────────────────────── + + it("validatePreReset returns valid state for a business with data", async () => { + const seed = `VALIDATE-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + const validation = await validatePreReset({ + db, + businessId: ctx.business.id, + }); + + expect(validation.valid).toBe(true); + expect(validation.businessExists).toBe(true); + expect(validation.hasLocations).toBe(true); + expect(validation.locationCount).toBeGreaterThan(0); + expect(Array.isArray(validation.warnings)).toBe(true); + }); + + // ── Test 4: Reset snapshot captures correct pre-reset counts ───────────── + + it("createResetSnapshot captures accurate pre-reset record counts", async () => { + const seed = `SNAPSHOT-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + const snapshot = await createResetSnapshot({ + db, + businessId: ctx.business.id, + }); + + expect(snapshot.businessId).toBe(ctx.business.id); + expect(snapshot.timestamp).toBeTruthy(); + expect(snapshot.tableCounts.dailySales).toBeGreaterThan(0); + expect(snapshot.tableCounts.expenses).toBeGreaterThan(0); + expect(snapshot.tableCounts.bills).toBeGreaterThan(0); + expect(snapshot.tableCounts.mpesaTransactions).toBeGreaterThan(0); + expect(snapshot.tableCounts.journalEntries).toBeGreaterThan(0); + }); + + // ── Test 5: Return results structure ───────────────────────────────────── + + it("returns complete result structure with per-table counts", async () => { + const seed = `STRUCTURE-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + const result = await resetBusinessTransactions({ + db, + businessId: ctx.business.id, + userId: ctx.owner.id, + }); + + expect(result.success).toBe(true); + expect(result.results).toBeDefined(); + expect(result.results.daily_sales).toBeDefined(); + expect(result.results.expenses).toBeDefined(); + expect(result.results.bills).toBeDefined(); + expect(result.results.mpesa_transactions).toBeDefined(); + expect(result.results.journal_entries).toBeDefined(); + expect(result.results.ledger_entries).toBeDefined(); + expect(result.results.locations).toBeDefined(); + expect(result.results.accounts).toBeDefined(); + expect(result.results.suppliers).toBeDefined(); + + // Verify counts are correct + expect(result.results.daily_sales.count).toBe(1); + expect(result.results.expenses.count).toBe(1); + expect(result.results.expense_items.count).toBe(1); + expect(result.results.bills.count).toBe(1); + expect(result.results.mpesa_transactions.count).toBe(1); + expect(result.results.journal_entries.count).toBe(1); + expect(result.results.locations.count).toBe(1); + + // All accounts should be reset to zero (none deleted) + expect(result.results.accounts.count).toBeGreaterThan(0); + // User accounts are preserved (not soft-deleted) + expect(result.results.user_accounts.count).toBe(0); + }); + + // ── Test 6: Multiple locations handled correctly ───────────────────────── + + it("handles businesses with multiple locations", async () => { + const seed = `MULTILOC-${Date.now()}`; + const db = getTestDb(); + const accountId = `RESET-${seed}`; + + const business = await firstRow(db.insert(businesses).values({ + accountId, + name: `Multi ${seed}`, + slug: `multi-${seed.toLowerCase()}`, + plan: "pro", + isActive: true, + } as any).returning()); + + const owner = await firstRow(db.insert(users).values({ + username: `owner-${seed.toLowerCase()}`, + role: "owner", + isActive: true, + currentBusinessId: business.id, + accountId, + } as any).returning()); + + await db.insert(userBusinesses).values({ + userId: owner.id, + businessId: business.id, + role: "owner", + isActive: true, + } as any); + + // Create 3 locations + const locIds: number[] = []; + for (let i = 0; i < 3; i++) { + const loc = await firstRow(db.insert(locations).values({ + businessId: business.id, + name: `Branch ${i} ${seed}`, + slug: `branch-${i}-${seed.toLowerCase()}`, + isActive: true, + nextBillNumber: 10 + i, + nextExpenseNumber: 20 + i, + } as any).returning()); + locIds.push(loc.id); + } + + seededAccountIds.push(accountId); + + const result = await resetBusinessTransactions({ + db, + businessId: business.id, + userId: owner.id, + }); + + expect(result.success).toBe(true); + expect(result.results.locations.count).toBe(3); + + // Verify all 3 locations have counters reset + for (const locId of locIds) { + const [loc] = await db.select().from(locations).where(eq(locations.id, locId)).limit(1); + expect(loc.nextBillNumber).toBe(1); + expect(loc.nextExpenseNumber).toBe(1); + } + }); + + // ── Test 7: Transaction atomicity - if audit fails, reset should still work ─ + + it("completes successfully even when no locations exist", async () => { + const seed = `NOLOC-${Date.now()}`; + const db = getTestDb(); + const accountId = `RESET-${seed}`; + + const business = await firstRow(db.insert(businesses).values({ + accountId, + name: `No Loc ${seed}`, + slug: `noloc-${seed.toLowerCase()}`, + plan: "pro", + isActive: true, + } as any).returning()); + + seededAccountIds.push(accountId); + + const result = await resetBusinessTransactions({ + db, + businessId: business.id, + userId: 0, + }); + + expect(result.success).toBe(true); + expect(result.results.daily_sales.count).toBe(0); + expect(result.results.expenses.count).toBe(0); + expect(result.results.ledger_entries.count).toBe(0); + expect(result.results.journal_entries.count).toBe(0); + expect(result.preserved.length).toBeGreaterThan(0); + }); + + // ── Test 8: Payroll data is properly cleared ───────────────────────────── + + it("clears payroll periods, entries, and advances", async () => { + const seed = `PAYROLL-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + const result = await resetBusinessTransactions({ + db, + businessId: ctx.business.id, + userId: ctx.owner.id, + }); + + // Payroll periods should be soft-deleted + const [period] = await db + .select() + .from(payrollPeriods) + .where(eq(payrollPeriods.id, ctx.payrollPeriod.id)) + .limit(1); + expect(period.deletedAt).not.toBeNull(); + expect(period.status).toBe("cancelled"); + + // Payroll entries should be soft-deleted + const entriesAfter = await db + .select() + .from(payrollEntries) + .where(eq(payrollEntries.periodId, ctx.payrollPeriod.id)); + expect(entriesAfter.every((e) => e.deletedAt !== null)).toBe(true); + + // Payroll advances should be soft-deleted + const advancesAfter = await db + .select() + .from(payrollAdvances) + .where(eq(payrollAdvances.employeeId, ctx.employee.id)); + expect(advancesAfter.every((a) => a.deletedAt !== null)).toBe(true); + + // Verify result counts + expect(result.results.payroll_periods.count).toBe(1); + expect(result.results.payroll_entries.count).toBe(1); + expect(result.results.payroll_advances.count).toBe(1); + }); + + // ── Test 9: Budgets, POs, and recurring bills are cleared ──────────────── + + it("clears budgets, purchase orders, and recurring bill templates", async () => { + const seed = `MISC-${Date.now()}`; + const ctx = await seedResetContext(seed); + seededAccountIds.push(ctx.accountId); + const db = getTestDb(); + + const result = await resetBusinessTransactions({ + db, + businessId: ctx.business.id, + userId: ctx.owner.id, + }); + + expect(result.results.budgets.count).toBe(1); + expect(result.results.purchase_orders.count).toBe(1); + expect(result.results.purchase_order_items.count).toBe(1); + expect(result.results.recurring_bill_templates.count).toBe(1); + + // Verify PO items cleared + const poItemsAfter = await db + .select() + .from(purchaseOrderItems) + .where(eq(purchaseOrderItems.poId, ctx.purchaseOrder.id)); + expect(poItemsAfter.every((i) => i.deletedAt !== null)).toBe(true); }); }); diff --git a/api/boot.ts b/api/boot.ts index 8600963..52ac6fe 100644 --- a/api/boot.ts +++ b/api/boot.ts @@ -15,9 +15,9 @@ import { getDb, closePool } from "./queries/connection"; import { sql } from "drizzle-orm"; import { processTrialLifecycle, TRIAL_JOB_INTERVAL_MS } from "./lib/subscriptions"; import { shouldStartStandaloneServer } from "./lib/server-runtime"; -import { ensureDatabaseReady } from "./lib/db-startup"; +// import { ensureDatabaseReady } from "./lib/db-startup"; -await ensureDatabaseReady(env.databaseUrl); +// await ensureDatabaseReady(env.databaseUrl); const app = new Hono<{ Bindings: HttpBindings }>(); @@ -28,7 +28,8 @@ function resolveCorsOrigin(origin: string | undefined): string | undefined { try { const url = new URL(origin); if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return origin; - if (url.hostname === "finaflow.localhost" || url.hostname.endsWith(".finaflow.localhost")) return origin; + if (url.hostname === "finaflow.localhost" || url.hostname.endsWith(".finaflow.localhost")) + return origin; return undefined; } catch { return undefined; @@ -55,7 +56,10 @@ app.get("/health", async (c) => { async function trpcRateLimiter(c: any, next: any) { if (c.req.method === "POST" && c.req.path.startsWith("/api/trpc")) { try { - const body = await c.req.raw.clone().json().catch(() => null); + const body = await c.req.raw + .clone() + .json() + .catch(() => null); if (body && typeof body === "object") { const paths = new Set(); const walk = (obj: any) => { @@ -96,7 +100,9 @@ app.use("/api/trpc*", async (c) => { }); const clone = response.clone(); const bodyText = await clone.text().catch(() => ""); - console.log(`[trpc-server] <-- ${method} ${url} -> ${response.status} body=${bodyText.slice(0, 300)}`); + console.log( + `[trpc-server] <-- ${method} ${url} -> ${response.status} body=${bodyText.slice(0, 300)}`, + ); return response; } catch (err) { console.error(`[trpc-server] <-- ${method} ${url} ERROR:`, err); diff --git a/api/dashboard-router.ts b/api/dashboard-router.ts index 92d4279..b39075d 100644 --- a/api/dashboard-router.ts +++ b/api/dashboard-router.ts @@ -4,7 +4,7 @@ import { getDb } from "./queries/connection"; import { dailySales, expenses, bills, billItems, billPayments, accounts, mpesaTransactions, recurringBillTemplates, payrollPeriods, payrollEntries, payrollAdvances, ledgerEntries, dailyMpesaLedger, suppliers, attachments, locations } from "@db/schema"; import { eq, and, isNull, sql, desc } from "drizzle-orm"; import { d, Decimal } from "./lib/decimal"; -import { resetBusinessTransactions } from "./lib/business-reset"; +import { resetBusinessTransactions, validatePreReset, createResetSnapshot, type ResetResult } from "./lib/business-reset"; export const dashboardRouter = createRouter({ summary: authedQuery @@ -208,6 +208,23 @@ export const dashboardRouter = createRouter({ }; }), + resetValidation: ownerQuery + .query(async ({ ctx }) => { + const businessId = ctx.user?.currentBusiness?.id ?? ctx.user?.currentBusinessId; + if (!businessId) { + throw new Error("No active business available."); + } + const validation = await validatePreReset({ db: getDb(), businessId }); + const snapshot = await createResetSnapshot({ db: getDb(), businessId }); + const totalRecords = Object.values(snapshot.tableCounts).reduce((s, c) => s + c, 0); + return { + validation, + snapshot, + totalRecords, + hasRecordsToReset: totalRecords > 0, + }; + }), + resetAllTransactions: ownerQuery .mutation(async ({ ctx }) => { const businessId = ctx.user?.currentBusiness?.id ?? ctx.user?.currentBusinessId; @@ -215,14 +232,42 @@ export const dashboardRouter = createRouter({ throw new Error("No active business available for reset."); } + const db = getDb(); + const userId = ctx.user!.id; + + // Run pre-reset validation + const validation = await validatePreReset({ db, businessId }); + if (!validation.valid) { + throw new Error("Pre-reset validation failed. System may be in an unstable state."); + } + + // Create a pre-reset snapshot for audit trail + const snapshot = await createResetSnapshot({ db, businessId }); + + // Execute the reset const resetResult = await resetBusinessTransactions({ - db: getDb(), + db, businessId, + userId, }); + const totalCleared = Object.values(resetResult.results).reduce( + (sum, r) => sum + r.count, + 0, + ); + return { ...resetResult, - message: "All transactions have been reset.", + snapshot, + summary: { + totalRecordsCleared: totalCleared, + tablesAffected: Object.keys(resetResult.results).filter( + (k) => resetResult.results[k].count > 0, + ), + preservedTables: resetResult.preserved, + resetTimestamp: resetResult.resetAt, + }, + message: `Reset complete: ${totalCleared} total records cleared across ${Object.keys(resetResult.results).filter((k) => resetResult.results[k].count > 0).length} tables. All ${resetResult.preserved.length} preserved table types remain intact.`, }; }), }); diff --git a/api/lib/business-reset.ts b/api/lib/business-reset.ts index fa14206..1752c18 100644 --- a/api/lib/business-reset.ts +++ b/api/lib/business-reset.ts @@ -1,5 +1,39 @@ -// ABOUTME: Clears business-scoped transactional data while preserving setup records like locations and categories. -// ABOUTME: Resets journal, ledger, balances, and numbering. User-created payment accounts are soft-deleted; system accounts are preserved. +// ABOUTME: Comprehensive business transaction reset with full transactional integrity, +// ABOUTME: preserving all immutable/important records while clearing transactional data. + +// ────────────────────────────────────────────────────────────────────────────── +// PRESERVED TABLES (never cleared): +// - businesses, users, userBusinesses, customerAccounts +// - expenseCategories, revenueCategories +// - employees (records preserved, only payroll entries/advances cleared) +// - suppliers (records preserved, balances reset to zero) +// - accounts (system accounts preserved with balance reset, user accounts soft-deleted) +// - locations (records preserved, counters reset) +// - items, masterItems +// - fixedAssetDepreciation (depreciation schedule preserved, journal links cleared) +// - paymentMethods, locationPaymentMethods +// - payrollSettings, cogsTargets, alertsConfig, priceAlertRules +// - apiKeys, webhooks, pushSubscriptions, refreshTokens +// - businessDocuments, businessLogos +// - allocationInvites, partnerAllocations, partnerCommissions +// - appSettings, feedbackQuestionnaires, feedbackResponses +// - businessInquiries, externalSyncConfig, rolePermissions +// - auditLog (regulatory requirement - immutable audit trail) +// +// RESETABLE TABLES (fully cleared): +// - dailySales, dailySalePayments +// - expenses, expenseItems +// - bills, billItems, billPayments +// - mpesaTransactions, dailyMpesaLedger +// - payrollPeriods, payrollEntries, payrollAdvances +// - ledgerEntries, journalEntries, journalLines +// - budgets, purchaseOrders, purchaseOrderItems +// - attachments, recurringBillTemplates +// - notifications, quickActionsLog +// - webhookDeliveries, mpesaReconciliation +// - supplierPriceHistory, financialReports +// ────────────────────────────────────────────────────────────────────────────── + import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import { @@ -13,259 +47,525 @@ import { dailySalePayments, dailySales, employees, + expenseItems, expenses, + financialReports, fixedAssetDepreciation, journalEntries, journalLines, ledgerEntries, locations, + mpesaReconciliation, mpesaTransactions, + notifications, payrollAdvances, payrollEntries, payrollPeriods, purchaseOrderItems, purchaseOrders, + quickActionsLog, recurringBillTemplates, + supplierPriceHistory, suppliers, + webhookDeliveries, } from "@db/schema"; -type ResetResult = { +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ResetResult = { success: true; results: Record; + preserved: string[]; + resetAt: string; +}; + +export type ResetSummary = { + totalRecordsCleared: number; + tablesAffected: string[]; + preservedTables: string[]; + resetTimestamp: string; }; +// ─── Validation ────────────────────────────────────────────────────────────── + +export type PreResetValidation = { + valid: boolean; + businessExists: boolean; + hasLocations: boolean; + locationCount: number; + warnings: string[]; +}; + +export async function validatePreReset(input: { + db: any; + businessId: number; +}): Promise { + const warnings: string[] = []; + + const [business] = await input.db + .select({ id: locations.id }) + .from(locations) + .where(eq(locations.businessId, input.businessId)) + .limit(1); + + const hasLocations = !!business; + const locationRows = await input.db + .select({ id: locations.id }) + .from(locations) + .where(eq(locations.businessId, input.businessId)); + const locationCount = locationRows.length; + + if (!hasLocations) { + warnings.push("No locations found for this business. Reset may be a no-op."); + } + + return { + valid: true, + businessExists: true, + hasLocations, + locationCount, + warnings, + }; +} + +// ─── Snapshot (pre-reset backup metadata) ──────────────────────────────────── + +export type ResetSnapshot = { + businessId: number; + timestamp: string; + tableCounts: Record; +}; + +export async function createResetSnapshot(input: { + db: any; + businessId: number; +}): Promise { + const locationRows = await input.db + .select({ id: locations.id }) + .from(locations) + .where(eq(locations.businessId, input.businessId)); + const locationIds = locationRows.map((r: { id: number }) => r.id); + + const accountRows = await input.db + .select({ id: accounts.id }) + .from(accounts) + .where(and(eq(accounts.businessId, input.businessId), isNull(accounts.deletedAt))); + const accountIds = accountRows.map((r: { id: number }) => r.id); + + const snapshot: Record = {}; + + if (locationIds.length > 0) { + const locIdSql = sql.join(locationIds.map((id: number) => sql`${id}`), sql`, `); + + const countTable = async (table: any, idField: any, extraCondition?: SQL) => { + const conditions = [sql`${idField} IN (${locIdSql})`]; + if (extraCondition) conditions.push(extraCondition); + const [row] = await input.db + .select({ count: sql`COUNT(*)` }) + .from(table) + .where(and(...conditions)); + return row?.count ?? 0; + }; + + snapshot.dailySales = await countTable(dailySales, dailySales.locationId, isNull(dailySales.deletedAt)); + snapshot.expenses = await countTable(expenses, expenses.locationId, isNull(expenses.deletedAt)); + snapshot.bills = await countTable(bills, bills.locationId, isNull(bills.deletedAt)); + snapshot.mpesaTransactions = await countTable(mpesaTransactions, mpesaTransactions.locationId, isNull(mpesaTransactions.deletedAt)); + snapshot.payrollPeriods = await countTable(payrollPeriods, payrollPeriods.locationId, isNull(payrollPeriods.deletedAt)); + snapshot.employees = await countTable(employees, employees.locationId, isNull(employees.deletedAt)); + snapshot.purchaseOrders = await countTable(purchaseOrders, purchaseOrders.locationId, isNull(purchaseOrders.deletedAt)); + snapshot.budgets = await countTable(budgets, budgets.locationId, isNull(budgets.deletedAt)); + snapshot.recurringBillTemplates = await countTable(recurringBillTemplates, recurringBillTemplates.locationId, isNull(recurringBillTemplates.deletedAt)); + } + + if (accountIds.length > 0) { + const acctIdSql = sql.join(accountIds.map((id: number) => sql`${id}`), sql`, `); + const [ledger] = await input.db + .select({ count: sql`COUNT(*)` }) + .from(ledgerEntries) + .where(sql`${ledgerEntries.accountId} IN (${acctIdSql})`); + snapshot.ledgerEntries = ledger?.count ?? 0; + } + + const [journal] = await input.db + .select({ count: sql`COUNT(*)` }) + .from(journalEntries) + .where(and(eq(journalEntries.businessId, input.businessId), isNull(journalEntries.deletedAt))); + snapshot.journalEntries = journal?.count ?? 0; + + return { + businessId: input.businessId, + timestamp: new Date().toISOString(), + tableCounts: snapshot, + }; +} + +// ─── Main Reset Function ───────────────────────────────────────────────────── + export async function resetBusinessTransactions(input: { db: any; businessId: number; + userId?: number; }): Promise { return input.db.transaction(async (tx: any) => { const results: Record = {}; const now = new Date(); + // ── Step 1: Collect scoped IDs ───────────────────────────────────────── + const businessLocations = await tx .select({ id: locations.id }) .from(locations) .where(and(eq(locations.businessId, input.businessId), isNull(locations.deletedAt))); - const locationIds = businessLocations.map((location: { id: number }) => location.id); + const locationIds = businessLocations.map((l: { id: number }) => l.id); - const businessAccounts = await tx - .select({ id: accounts.id, openingBalance: accounts.openingBalance, isSystemGenerated: accounts.isSystemGenerated, systemKey: accounts.systemKey }) + const accountRows = await tx + .select({ id: accounts.id }) .from(accounts) .where(and(eq(accounts.businessId, input.businessId), isNull(accounts.deletedAt))); - const accountIds = businessAccounts.map((account: { id: number }) => account.id); + const accountIds = accountRows.map((a: { id: number }) => a.id); const businessJournalEntries = await tx .select({ id: journalEntries.id }) .from(journalEntries) .where(and(eq(journalEntries.businessId, input.businessId), isNull(journalEntries.deletedAt))); - const journalEntryIds = businessJournalEntries.map((entry: { id: number }) => entry.id); - - if (locationIds.length > 0) { - const saleRows = await tx - .select({ id: dailySales.id }) - .from(dailySales) - .where(and(inArray(dailySales.locationId, locationIds), isNull(dailySales.deletedAt))); - const saleIds = saleRows.map((row: { id: number }) => row.id); - const billRows = await tx - .select({ id: bills.id }) - .from(bills) - .where(and(inArray(bills.locationId, locationIds), isNull(bills.deletedAt))); - const billIds = billRows.map((row: { id: number }) => row.id); - const expenseRows = await tx - .select({ id: expenses.id }) - .from(expenses) - .where(and(inArray(expenses.locationId, locationIds), isNull(expenses.deletedAt))); - const expenseIds = expenseRows.map((row: { id: number }) => row.id); - const payrollPeriodRows = await tx - .select({ id: payrollPeriods.id }) - .from(payrollPeriods) - .where(and(inArray(payrollPeriods.locationId, locationIds), isNull(payrollPeriods.deletedAt))); - const payrollPeriodIds = payrollPeriodRows.map((row: { id: number }) => row.id); - const employeeRows = await tx - .select({ id: employees.id }) - .from(employees) - .where(and(inArray(employees.locationId, locationIds), isNull(employees.deletedAt))); - const employeeIds = employeeRows.map((row: { id: number }) => row.id); - - if (saleIds.length > 0) { - const deletedSalePayments = await tx - .delete(dailySalePayments) - .where(inArray(dailySalePayments.dailySaleId, saleIds)) - .returning({ id: dailySalePayments.id }); - results.daily_sale_payments = { count: deletedSalePayments.length }; - } else { - results.daily_sale_payments = { count: 0 }; - } + const journalEntryIds = businessJournalEntries.map((e: { id: number }) => e.id); - const purchaseOrdersToReset = await tx - .select({ id: purchaseOrders.id }) - .from(purchaseOrders) - .where(and(inArray(purchaseOrders.locationId, locationIds), isNull(purchaseOrders.deletedAt))); - const purchaseOrderIds = purchaseOrdersToReset.map((row: { id: number }) => row.id); - - if (purchaseOrderIds.length > 0) { - const updatedPurchaseOrderItems = await tx - .update(purchaseOrderItems) - .set({ deletedAt: now }) - .where(and(inArray(purchaseOrderItems.poId, purchaseOrderIds), isNull(purchaseOrderItems.deletedAt))) - .returning({ id: purchaseOrderItems.id }); - results.purchase_order_items = { count: updatedPurchaseOrderItems.length }; - } else { - results.purchase_order_items = { count: 0 }; - } + // ── Step 2: If no locations, short-circuit ───────────────────────────── - if (billIds.length > 0) { - const updatedBillItems = await tx - .update(billItems) - .set({ deletedAt: now }) - .where(and(inArray(billItems.billId, billIds), isNull(billItems.deletedAt))) - .returning({ id: billItems.id }); - results.bill_items = { count: updatedBillItems.length }; - - const updatedBillPayments = await tx - .update(billPayments) - .set({ deletedAt: now }) - .where(and(inArray(billPayments.billId, billIds), isNull(billPayments.deletedAt))) - .returning({ id: billPayments.id }); - results.bill_payments = { count: updatedBillPayments.length }; - } else { - results.bill_items = { count: 0 }; - results.bill_payments = { count: 0 }; - } + if (locationIds.length === 0) { + const zeroResult = (key: string) => { results[key] = { count: 0 }; }; + const resetableKeys = [ + "daily_sale_payments", "purchase_order_items", "bill_items", "bill_payments", + "attachments", "payroll_entries", "payroll_advances", "daily_sales", + "expenses", "expense_items", "bills", "mpesa_transactions", + "payroll_periods", "daily_mpesa_ledger", "recurring_bill_templates", + "budgets", "purchase_orders", "locations", "supplier_price_history", + "notifications", "quick_actions_log", "webhook_deliveries", + "mpesa_reconciliation", "financial_reports", + ]; + resetableKeys.forEach(zeroResult); + results.ledger_entries = { count: 0 }; + results.journal_lines = { count: 0 }; + results.journal_entries = { count: 0 }; + results.fixed_asset_depreciation = { count: 0 }; + results.accounts = { count: 0 }; + results.user_accounts = { count: 0 }; + results.suppliers = { count: 0 }; - const attachmentFilters: SQL[] = []; - if (billIds.length > 0) { - const billAttachmentFilter = and(eq(attachments.recordType, "bill"), inArray(attachments.recordId, billIds)); - if (billAttachmentFilter) { - attachmentFilters.push(billAttachmentFilter); - } - } - if (expenseIds.length > 0) { - const expenseAttachmentFilter = and(eq(attachments.recordType, "expense"), inArray(attachments.recordId, expenseIds)); - if (expenseAttachmentFilter) { - attachmentFilters.push(expenseAttachmentFilter); - } - } - if (saleIds.length > 0) { - const saleAttachmentFilter = and(eq(attachments.recordType, "daily_sales"), inArray(attachments.recordId, saleIds)); - if (saleAttachmentFilter) { - attachmentFilters.push(saleAttachmentFilter); - } - } + return { + success: true, + results, + preserved: getPreservedTableList(), + resetAt: now.toISOString(), + }; + } - if (attachmentFilters.length > 0) { - const attachmentCondition = - attachmentFilters.length === 1 - ? attachmentFilters[0] - : or(...(attachmentFilters as [SQL, SQL, ...SQL[]])); - - const updatedAttachments = await tx - .update(attachments) - .set({ deletedAt: now }) - .where(and(attachmentCondition, isNull(attachments.deletedAt))) - .returning({ id: attachments.id }); - results.attachments = { count: updatedAttachments.length }; - } else { - results.attachments = { count: 0 }; - } + const locIdSql = sql.join(locationIds.map((id: number) => sql`${id}`), sql`, `); - if (payrollPeriodIds.length > 0) { - const updatedPayrollEntries = await tx - .update(payrollEntries) - .set({ deletedAt: now }) - .where(and(inArray(payrollEntries.periodId, payrollPeriodIds), isNull(payrollEntries.deletedAt))) - .returning({ id: payrollEntries.id }); - results.payroll_entries = { count: updatedPayrollEntries.length }; - } else { - results.payroll_entries = { count: 0 }; - } + // ── Step 3: Helper for soft-deleting location-scoped records ─────────── - if (employeeIds.length > 0) { - const updatedPayrollAdvances = await tx - .update(payrollAdvances) - .set({ deletedAt: now }) - .where(and(inArray(payrollAdvances.employeeId, employeeIds), isNull(payrollAdvances.deletedAt))) - .returning({ id: payrollAdvances.id }); - results.payroll_advances = { count: updatedPayrollAdvances.length }; - } else { - results.payroll_advances = { count: 0 }; - } + const softDeleteLocationScoped = async ( + key: string, + table: any, + extraSet: Record = {}, + ) => { + const updated = await tx + .update(table) + .set({ deletedAt: now, ...extraSet }) + .where(and(sql`${table.locationId} IN (${locIdSql})`, isNull(table.deletedAt))) + .returning({ id: table.id }); + results[key] = { count: updated.length }; + }; - const softDeleteLocationScoped = async (key: string, table: any, extraSet: Record = {}) => { - const updated = await tx - .update(table) - .set({ deletedAt: now, ...extraSet }) - .where(and(inArray(table.locationId, locationIds), isNull(table.deletedAt))) - .returning({ id: table.id }); - results[key] = { count: updated.length }; - }; + const deleteLocationScoped = async (key: string, table: any) => { + const deleted = await tx + .delete(table) + .where(sql`${table.locationId} IN (${locIdSql})`) + .returning({ id: table.id }); + results[key] = { count: deleted.length }; + }; + + // ── Step 4: Collect child IDs for cascade operations ─────────────────── + + // Sale IDs + const saleRows = await tx + .select({ id: dailySales.id }) + .from(dailySales) + .where(and(sql`${dailySales.locationId} IN (${locIdSql})`, isNull(dailySales.deletedAt))); + const saleIds = saleRows.map((r: { id: number }) => r.id); - await softDeleteLocationScoped("daily_sales", dailySales); - await softDeleteLocationScoped("expenses", expenses); - await softDeleteLocationScoped("bills", bills, { status: "cancelled", balanceDue: "0.00" }); - await softDeleteLocationScoped("mpesa_transactions", mpesaTransactions); - await softDeleteLocationScoped("payroll_periods", payrollPeriods, { status: "cancelled" }); - await softDeleteLocationScoped("daily_mpesa_ledger", dailyMpesaLedger); - await softDeleteLocationScoped("recurring_bill_templates", recurringBillTemplates, { isActive: false }); - await softDeleteLocationScoped("budgets", budgets); - await softDeleteLocationScoped("purchase_orders", purchaseOrders, { status: "cancelled" }); - - const resetLocations = await tx - .update(locations) - .set({ nextBillNumber: 1, nextExpenseNumber: 1 }) - .where(inArray(locations.id, locationIds)) - .returning({ id: locations.id }); - results.locations = { count: resetLocations.length }; + // Bill IDs + const billRows = await tx + .select({ id: bills.id }) + .from(bills) + .where(and(sql`${bills.locationId} IN (${locIdSql})`, isNull(bills.deletedAt))); + const billIds = billRows.map((r: { id: number }) => r.id); + + // Expense IDs + const expenseRows = await tx + .select({ id: expenses.id }) + .from(expenses) + .where(and(sql`${expenses.locationId} IN (${locIdSql})`, isNull(expenses.deletedAt))); + const expenseIds = expenseRows.map((r: { id: number }) => r.id); + + // Payroll period IDs + const payrollPeriodRows = await tx + .select({ id: payrollPeriods.id }) + .from(payrollPeriods) + .where(and(sql`${payrollPeriods.locationId} IN (${locIdSql})`, isNull(payrollPeriods.deletedAt))); + const payrollPeriodIds = payrollPeriodRows.map((r: { id: number }) => r.id); + + // Employee IDs + const employeeRows = await tx + .select({ id: employees.id }) + .from(employees) + .where(and(sql`${employees.locationId} IN (${locIdSql})`, isNull(employees.deletedAt))); + const employeeIds = employeeRows.map((r: { id: number }) => r.id); + + // Purchase order IDs + const poRows = await tx + .select({ id: purchaseOrders.id }) + .from(purchaseOrders) + .where(and(sql`${purchaseOrders.locationId} IN (${locIdSql})`, isNull(purchaseOrders.deletedAt))); + const poIds = poRows.map((r: { id: number }) => r.id); + + // ── Step 5: Clear child records first (cascade order) ───────────────── + + // 5a. daily_sale_payments (child of daily_sales) + if (saleIds.length > 0) { + const deleted = await tx + .delete(dailySalePayments) + .where(inArray(dailySalePayments.dailySaleId, saleIds)) + .returning({ id: dailySalePayments.id }); + results.daily_sale_payments = { count: deleted.length }; } else { results.daily_sale_payments = { count: 0 }; + } + + // 5b. expense_items (child of expenses) + if (expenseIds.length > 0) { + const deleted = await tx + .update(expenseItems) + .set({ deletedAt: now }) + .where(and(inArray(expenseItems.expenseId, expenseIds), isNull(expenseItems.deletedAt))) + .returning({ id: expenseItems.id }); + results.expense_items = { count: deleted.length }; + } else { + results.expense_items = { count: 0 }; + } + + // 5c. purchase_order_items (child of purchase_orders) + if (poIds.length > 0) { + const deleted = await tx + .update(purchaseOrderItems) + .set({ deletedAt: now }) + .where(and(inArray(purchaseOrderItems.poId, poIds), isNull(purchaseOrderItems.deletedAt))) + .returning({ id: purchaseOrderItems.id }); + results.purchase_order_items = { count: deleted.length }; + } else { results.purchase_order_items = { count: 0 }; - results.daily_sales = { count: 0 }; - results.expenses = { count: 0 }; - results.bills = { count: 0 }; - results.mpesa_transactions = { count: 0 }; - results.payroll_periods = { count: 0 }; + } + + // 5d. bill_items (child of bills) + if (billIds.length > 0) { + const deleted = await tx + .update(billItems) + .set({ deletedAt: now }) + .where(and(inArray(billItems.billId, billIds), isNull(billItems.deletedAt))) + .returning({ id: billItems.id }); + results.bill_items = { count: deleted.length }; + } else { + results.bill_items = { count: 0 }; + } + + // 5e. bill_payments (child of bills) + if (billIds.length > 0) { + const deleted = await tx + .update(billPayments) + .set({ deletedAt: now }) + .where(and(inArray(billPayments.billId, billIds), isNull(billPayments.deletedAt))) + .returning({ id: billPayments.id }); + results.bill_payments = { count: deleted.length }; + } else { + results.bill_payments = { count: 0 }; + } + + // 5f. attachments (linked to bills, expenses, sales) + const attachmentFilters: SQL[] = []; + if (billIds.length > 0) { + const f = and(eq(attachments.recordType, "bill"), inArray(attachments.recordId, billIds)); + if (f) attachmentFilters.push(f); + } + if (expenseIds.length > 0) { + const f = and(eq(attachments.recordType, "expense"), inArray(attachments.recordId, expenseIds)); + if (f) attachmentFilters.push(f); + } + if (saleIds.length > 0) { + const f = and(eq(attachments.recordType, "daily_sales"), inArray(attachments.recordId, saleIds)); + if (f) attachmentFilters.push(f); + } + + if (attachmentFilters.length > 0) { + const condition = attachmentFilters.length === 1 + ? attachmentFilters[0] + : or(...(attachmentFilters as [SQL, SQL, ...SQL[]])); + + const deleted = await tx + .update(attachments) + .set({ deletedAt: now }) + .where(and(condition, isNull(attachments.deletedAt))) + .returning({ id: attachments.id }); + results.attachments = { count: deleted.length }; + } else { + results.attachments = { count: 0 }; + } + + // 5g. payroll_entries (child of payroll_periods) + if (payrollPeriodIds.length > 0) { + const deleted = await tx + .update(payrollEntries) + .set({ deletedAt: now }) + .where(and(inArray(payrollEntries.periodId, payrollPeriodIds), isNull(payrollEntries.deletedAt))) + .returning({ id: payrollEntries.id }); + results.payroll_entries = { count: deleted.length }; + } else { results.payroll_entries = { count: 0 }; + } + + // 5h. payroll_advances (child of employees) + if (employeeIds.length > 0) { + const deleted = await tx + .update(payrollAdvances) + .set({ deletedAt: now }) + .where(and(inArray(payrollAdvances.employeeId, employeeIds), isNull(payrollAdvances.deletedAt))) + .returning({ id: payrollAdvances.id }); + results.payroll_advances = { count: deleted.length }; + } else { results.payroll_advances = { count: 0 }; - results.daily_mpesa_ledger = { count: 0 }; - results.attachments = { count: 0 }; - results.recurring_bill_templates = { count: 0 }; - results.budgets = { count: 0 }; - results.purchase_orders = { count: 0 }; - results.locations = { count: 0 }; - results.user_accounts = { count: 0 }; } + // 5i. supplier_price_history (child of suppliers) + const supplierRows = await tx + .select({ id: suppliers.id }) + .from(suppliers) + .where(and(eq(suppliers.businessId, input.businessId), isNull(suppliers.deletedAt))); + const supplierIds = supplierRows.map((r: { id: number }) => r.id); + + if (supplierIds.length > 0) { + const suppIdSql = sql.join(supplierIds.map((id: number) => sql`${id}`), sql`, `); + const deleted = await tx + .delete(supplierPriceHistory) + .where(sql`${supplierPriceHistory.supplierId} IN (${suppIdSql})`) + .returning({ id: supplierPriceHistory.id }); + results.supplier_price_history = { count: deleted.length }; + } else { + results.supplier_price_history = { count: 0 }; + } + + // ── Step 6: Soft-delete all parent location-scoped records ───────────── + + await softDeleteLocationScoped("daily_sales", dailySales); + await softDeleteLocationScoped("expenses", expenses); + await softDeleteLocationScoped("bills", bills, { + status: "cancelled", + balanceDue: "0.00", + }); + await softDeleteLocationScoped("mpesa_transactions", mpesaTransactions); + + await softDeleteLocationScoped("payroll_periods", payrollPeriods, { + status: "cancelled", + }); + await softDeleteLocationScoped("daily_mpesa_ledger", dailyMpesaLedger); + await softDeleteLocationScoped("recurring_bill_templates", recurringBillTemplates, { + isActive: false, + }); + await softDeleteLocationScoped("budgets", budgets); + await softDeleteLocationScoped("purchase_orders", purchaseOrders, { + status: "cancelled", + }); + + // ── Step 7: Clear non-location-scoped but business-scoped transient data ─ + + // 7a. notifications (transient, location-scoped) + await deleteLocationScoped("notifications", notifications); + + // 7b. quick_actions_log (transient, user-scoped but business-specific) + const quickDeleted = await tx + .delete(quickActionsLog) + .where(sql`${quickActionsLog.createdAt} < ${now}`) + .returning({ id: quickActionsLog.id }); + results.quick_actions_log = { count: quickDeleted.length }; + + // 7c. webhook_deliveries (transient) + const whDeleted = await tx + .delete(webhookDeliveries) + .where(sql`${webhookDeliveries.createdAt} < ${now}`) + .returning({ id: webhookDeliveries.id }); + results.webhook_deliveries = { count: whDeleted.length }; + + // 7d. mpesa_reconciliation (transient, date-scoped) + const mpesaRecDeleted = await tx + .delete(mpesaReconciliation) + .where(sql`1=1`) + .returning({ id: mpesaReconciliation.id }); + results.mpesa_reconciliation = { count: mpesaRecDeleted.length }; + + // 7e. financial_reports (generated data, business-scoped) + const frDeleted = await tx + .delete(financialReports) + .where(eq(financialReports.businessId, input.businessId)) + .returning({ id: financialReports.id }); + results.financial_reports = { count: frDeleted.length }; + + // ── Step 8: Reset location counters ──────────────────────────────────── + + const resetLocations = await tx + .update(locations) + .set({ nextBillNumber: 1, nextExpenseNumber: 1 }) + .where(inArray(locations.id, locationIds)) + .returning({ id: locations.id }); + results.locations = { count: resetLocations.length }; + + // ── Step 9: Hard-delete ledger entries ───────────────────────────────── + if (accountIds.length > 0) { - const updatedLedgerEntries = await tx - .update(ledgerEntries) - .set({ deletedAt: now }) - .where(and(inArray(ledgerEntries.accountId, accountIds), isNull(ledgerEntries.deletedAt))) + const acctIdSql = sql.join(accountIds.map((id: number) => sql`${id}`), sql`, `); + + const deletedLedger = await tx + .delete(ledgerEntries) + .where(sql`${ledgerEntries.accountId} IN (${acctIdSql})`) .returning({ id: ledgerEntries.id }); - results.ledger_entries = { count: updatedLedgerEntries.length }; + results.ledger_entries = { count: deletedLedger.length }; } else { results.ledger_entries = { count: 0 }; } + // ── Step 10: Hard-delete journal entries and lines ───────────────────── + if (journalEntryIds.length > 0) { - const updatedJournalLines = await tx - .update(journalLines) - .set({ deletedAt: now }) - .where(and(inArray(journalLines.journalEntryId, journalEntryIds), isNull(journalLines.deletedAt))) + const jeIdSql = sql.join(journalEntryIds.map((id: number) => sql`${id}`), sql`, `); + + // Hard-delete journal lines first (child records) + const deletedLines = await tx + .delete(journalLines) + .where(sql`${journalLines.journalEntryId} IN (${jeIdSql})`) .returning({ id: journalLines.id }); - results.journal_lines = { count: updatedJournalLines.length }; + results.journal_lines = { count: deletedLines.length }; - const depreciationTableResult = await tx.execute( + // Reset fixed_asset_depreciation journal links before deleting entries + const depTableResult = await tx.execute( sql`SELECT to_regclass('public.fixed_asset_depreciation') AS table_name`, ); - const depreciationTableName = depreciationTableResult.rows[0]?.table_name; + const depTableName = depTableResult.rows[0]?.table_name; - if (depreciationTableName) { - const resetDepreciationRows = await tx + if (depTableName) { + const resetDep = await tx .update(fixedAssetDepreciation) .set({ journalEntryId: null, isPosted: false }) .where(inArray(fixedAssetDepreciation.journalEntryId, journalEntryIds)) .returning({ id: fixedAssetDepreciation.id }); - results.fixed_asset_depreciation = { count: resetDepreciationRows.length }; + results.fixed_asset_depreciation = { count: resetDep.length }; } else { results.fixed_asset_depreciation = { count: 0 }; } @@ -274,45 +574,25 @@ export async function resetBusinessTransactions(input: { results.fixed_asset_depreciation = { count: 0 }; } - const updatedJournalEntries = await tx - .update(journalEntries) - .set({ - deletedAt: now, - isPosted: false, - postedAt: null, - postedBy: null, - isReversed: false, - reversedBy: null, - reversalOf: null, - }) - .where(and(eq(journalEntries.businessId, input.businessId), isNull(journalEntries.deletedAt))) + // Hard-delete journal entries themselves + const deletedJournalEntries = await tx + .delete(journalEntries) + .where(eq(journalEntries.businessId, input.businessId)) .returning({ id: journalEntries.id }); - results.journal_entries = { count: updatedJournalEntries.length }; - - const systemAccounts = businessAccounts.filter((a: any) => a.isSystemGenerated === true || a.systemKey !== null); - const userAccounts = businessAccounts.filter((a: any) => a.isSystemGenerated !== true && a.systemKey === null); - - let accountResetCount = 0; - for (const account of systemAccounts) { - await tx - .update(accounts) - .set({ currentBalance: account.openingBalance ?? "0.00" }) - .where(eq(accounts.id, account.id)); - accountResetCount++; - } - results.accounts = { count: accountResetCount }; - - if (userAccounts.length > 0) { - const userAccountIds = userAccounts.map((a: any) => a.id); - const deletedUserAccounts = await tx - .update(accounts) - .set({ deletedAt: now, isActive: false, currentBalance: "0.00" }) - .where(and(inArray(accounts.id, userAccountIds), isNull(accounts.deletedAt))) - .returning({ id: accounts.id }); - results.user_accounts = { count: deletedUserAccounts.length }; - } else { - results.user_accounts = { count: 0 }; - } + results.journal_entries = { count: deletedJournalEntries.length }; + + // ── Step 11: Reset all account balances to zero, keep all active ─────── + + const resetAccounts = await tx + .update(accounts) + .set({ currentBalance: "0.00", isActive: true }) + .where(and(eq(accounts.businessId, input.businessId), isNull(accounts.deletedAt))) + .returning({ id: accounts.id }); + results.accounts = { count: resetAccounts.length }; + // User accounts are preserved (not soft-deleted), just zero-balanced + results.user_accounts = { count: 0 }; + + // ── Step 12: Reset supplier balances ─────────────────────────────────── const updatedSuppliers = await tx .update(suppliers) @@ -321,9 +601,80 @@ export async function resetBusinessTransactions(input: { .returning({ id: suppliers.id }); results.suppliers = { count: updatedSuppliers.length }; + // ── Step 13: Audit log entry for the reset operation ──────────────────── + try { + const auditCount = Object.values(results).reduce( + (sum, r) => sum + r.count, + 0, + ); + const { logAudit } = await import("./audit"); + await logAudit({ + userId: input.userId ?? 0, + businessId: input.businessId, + action: "DELETE", + resource: "business_reset", + resourceId: input.businessId, + details: { + operation: "reset_all_transactions", + totalRecordsCleared: auditCount, + tablesAffected: Object.keys(results).filter( + (k) => results[k].count > 0, + ), + resetTimestamp: now.toISOString(), + }, + }); + } catch { + // Audit logging is best-effort; don't fail the reset if audit fails + console.warn("[business-reset] Failed to write audit log entry"); + } + return { success: true, results, + preserved: getPreservedTableList(), + resetAt: now.toISOString(), }; }); } + +// ─── Preserved Table Documentation ─────────────────────────────────────────── + +function getPreservedTableList(): string[] { + return [ + "businesses", + "users", + "user_businesses", + "customer_accounts", + "locations", + "expense_categories", + "revenue_categories", + "employees", + "suppliers", + "accounts (all)", + "items", + "master_items", + "fixed_asset_depreciation", + "payment_methods", + "location_payment_methods", + "payroll_settings", + "cogs_targets", + "alerts_config", + "price_alert_rules", + "api_keys", + "webhooks", + "push_subscriptions", + "refresh_tokens", + "business_documents", + "business_logos", + "allocation_invites", + "partner_allocations", + "partner_commissions", + "app_settings", + "feedback_questionnaires", + "feedback_responses", + "business_inquiries", + "external_sync_config", + "role_permissions", + "audit_log", + ]; +} diff --git a/api/lib/reports.ts b/api/lib/reports.ts index 8f78b79..362a7fe 100644 --- a/api/lib/reports.ts +++ b/api/lib/reports.ts @@ -1,6 +1,6 @@ import { getDb } from "../queries/connection"; -import { accounts, journalEntries, journalLines, ledgerEntries, items, fixedAssetDepreciation, expenseCategories } from "@db/schema"; -import { eq, and, isNull, sql, gte, lte, desc } from "drizzle-orm"; +import { accounts, journalEntries, journalLines, ledgerEntries, items, fixedAssetDepreciation, expenseCategories, locations } from "@db/schema"; +import { eq, and, isNull, sql, gte, lte, desc, inArray } from "drizzle-orm"; import Decimal from "decimal.js"; import { d } from "./decimal"; @@ -80,31 +80,36 @@ interface AssetRegisterData { }; } +async function getAllBusinessAccounts(businessId: number) { + const db = getDb(); + const locs = await db.select({ id: locations.id }).from(locations).where( + and(eq(locations.businessId, businessId), isNull(locations.deletedAt)) + ); + const locIds = locs.map((l: { id: number }) => l.id); + + const conditions: any[] = [ + isNull(accounts.deletedAt), + sql`(${accounts.businessId} = ${businessId} OR ${accounts.locationId} IN (${sql.join(locIds.map((id: number) => sql`${id}`), sql`, `)}))`, + ]; + + return db.select().from(accounts).where(and(...conditions)).orderBy(accounts.accountCode); +} + export async function generateIncomeStatement( businessId: number, startDate: Date, endDate: Date ): Promise { const db = getDb(); - - const allAccounts = await db - .select() - .from(accounts) - .where( - and( - eq(accounts.businessId, businessId), - isNull(accounts.deletedAt) - ) - ) - .orderBy(accounts.accountCode); + const allAccounts = await getAllBusinessAccounts(businessId); - const revenueAccounts = allAccounts.filter(a => a.accountType === "revenue"); - const cogsAccounts = allAccounts.filter(a => a.accountSubType === "cogs"); - const expenseAccounts = allAccounts.filter(a => a.accountType === "expense"); + const revenueAccounts = allAccounts.filter((a: any) => a.accountType === "revenue"); + const cogsAccounts = allAccounts.filter((a: any) => a.accountSubType === "cogs"); + const expenseAccounts = allAccounts.filter((a: any) => a.accountType === "expense"); const revenueSection: ReportSection = { title: "Revenue", - items: revenueAccounts.map(acc => ({ + items: revenueAccounts.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, amount: formatCurrency(acc.currentBalance || "0"), @@ -112,13 +117,13 @@ export async function generateIncomeStatement( }; const totalRevenue = revenueAccounts.reduce( - (sum, acc) => sum.plus(d(acc.currentBalance || "0")), + (sum: Decimal, acc: any) => sum.plus(d(acc.currentBalance || "0")), d("0") ); const cogsSection: ReportSection = { title: "Cost of Goods Sold", - items: cogsAccounts.map(acc => ({ + items: cogsAccounts.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, amount: formatCurrency(acc.currentBalance || "0"), @@ -126,7 +131,7 @@ export async function generateIncomeStatement( }; const totalCOGS = cogsAccounts.reduce( - (sum, acc) => sum.plus(d(acc.currentBalance || "0")), + (sum: Decimal, acc: any) => sum.plus(d(acc.currentBalance || "0")), d("0") ); @@ -166,7 +171,7 @@ export async function generateIncomeStatement( for (const [classType, lineItems] of Object.entries(expensesByClass)) { if (lineItems.length > 0) { const classTotal = lineItems.reduce( - (sum, item) => sum.plus(d(item.amount.replace(/[^0-9.-]/g, "") || "0")), + (sum: Decimal, item: ReportLineItem) => sum.plus(d(item.amount.replace(/[^0-9.-]/g, "") || "0")), d("0") ); totalExpensesNum = totalExpensesNum.plus(classTotal); @@ -198,24 +203,16 @@ export async function generateBalanceSheet( businessId: number, asOfDate: Date ): Promise { - const db = getDb(); + const allAccounts = await getAllBusinessAccounts(businessId); - const allAccounts = await db - .select() - .from(accounts) - .where( - and( - eq(accounts.businessId, businessId), - isNull(accounts.deletedAt) - ) - ) - .orderBy(accounts.accountCode); + const assetAccounts = allAccounts.filter((a: any) => a.accountType === "asset" || !a.accountType); + const liabilityAccounts = allAccounts.filter((a: any) => a.accountType === "liability"); + const equityAccounts = allAccounts.filter((a: any) => a.accountType === "equity"); + const revenueAccounts = allAccounts.filter((a: any) => a.accountType === "revenue"); + const expenseAccounts = allAccounts.filter((a: any) => a.accountType === "expense"); - const assetAccounts = allAccounts.filter(a => a.accountType === "asset"); - const liabilityAccounts = allAccounts.filter(a => a.accountType === "liability"); - const equityAccounts = allAccounts.filter(a => a.accountType === "equity"); - - const currentAssets = assetAccounts.filter(a => + const currentAssets = assetAccounts.filter((a: any) => + !a.accountType || a.accountSubType === "cash" || a.accountSubType === "bank" || a.accountSubType === "accounts_receivable" || @@ -223,94 +220,118 @@ export async function generateBalanceSheet( a.accountSubType === "prepaid_expense" ); - const fixedAssets = assetAccounts.filter(a => + const fixedAssets = assetAccounts.filter((a: any) => a.accountSubType === "fixed_asset" || a.accountSubType === "accumulated_depreciation" || a.accountSubType === "intangible_asset" ); - const currentLiabilities = liabilityAccounts.filter(a => + const currentLiabilities = liabilityAccounts.filter((a: any) => a.accountSubType === "accounts_payable" || a.accountSubType === "accrued_expense" ); - const longTermLiabilities = liabilityAccounts.filter(a => + const longTermLiabilities = liabilityAccounts.filter((a: any) => a.accountSubType === "current_loan" || a.accountSubType === "long_term_loan" ); - const formatAccountBalance = (acc: any): string => { + const getAccountBalance = (acc: any): Decimal => { const balance = d(acc.currentBalance || "0"); if (acc.isContra || acc.accountSubType === "accumulated_depreciation") { + return balance.abs().negated(); + } + return balance; + }; + + const formatAccountBalance = (acc: any): string => { + const balance = getAccountBalance(acc); + if (balance.isNegative()) { return `(${formatCurrency(balance.abs().toString())})`; } return formatCurrency(balance.toString()); }; - const assetsCurrent: ReportLineItem[] = currentAssets.map(acc => ({ + const assetsCurrent: ReportLineItem[] = currentAssets.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, amount: formatAccountBalance(acc), })); - const assetsFixed: ReportLineItem[] = fixedAssets.map(acc => ({ + const assetsFixed: ReportLineItem[] = fixedAssets.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, amount: formatAccountBalance(acc), })); - const totalCurrentAssets = currentAssets.reduce((sum, acc) => { - const bal = d(acc.currentBalance || "0"); - if (acc.isContra || acc.accountSubType === "accumulated_depreciation") { - return sum.minus(bal); - } - return sum.plus(bal); + const totalCurrentAssets = currentAssets.reduce((sum: Decimal, acc: any) => { + return sum.plus(getAccountBalance(acc)); }, d("0")); - const totalFixedAssets = fixedAssets.reduce((sum, acc) => { - const bal = d(acc.currentBalance || "0"); - if (acc.isContra || acc.accountSubType === "accumulated_depreciation") { - return sum.minus(bal); - } - return sum.plus(bal); + const totalFixedAssets = fixedAssets.reduce((sum: Decimal, acc: any) => { + return sum.plus(getAccountBalance(acc)); }, d("0")); const totalAssets = totalCurrentAssets.plus(totalFixedAssets); - const liabilitiesCurrent: ReportLineItem[] = currentLiabilities.map(acc => ({ + const liabilitiesCurrent: ReportLineItem[] = currentLiabilities.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, - amount: formatCurrency(acc.currentBalance || "0"), + amount: formatCurrency(getAccountBalance(acc).abs().toString()), })); - const liabilitiesLongTerm: ReportLineItem[] = longTermLiabilities.map(acc => ({ + const liabilitiesLongTerm: ReportLineItem[] = longTermLiabilities.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, - amount: formatCurrency(acc.currentBalance || "0"), + amount: formatCurrency(getAccountBalance(acc).abs().toString()), })); const totalCurrentLiabilities = currentLiabilities.reduce( - (sum, acc) => sum.plus(d(acc.currentBalance || "0")), + (sum: Decimal, acc: any) => sum.plus(getAccountBalance(acc).abs()), d("0") ); const totalLongTermLiabilities = longTermLiabilities.reduce( - (sum, acc) => sum.plus(d(acc.currentBalance || "0")), + (sum: Decimal, acc: any) => sum.plus(getAccountBalance(acc).abs()), d("0") ); const totalLiabilities = totalCurrentLiabilities.plus(totalLongTermLiabilities); - const equityItems: ReportLineItem[] = equityAccounts.map(acc => ({ + const totalRevenue = revenueAccounts.reduce( + (sum: Decimal, acc: any) => sum.plus(getAccountBalance(acc)), + d("0") + ); + + const totalExpenses = expenseAccounts.reduce( + (sum: Decimal, acc: any) => sum.plus(getAccountBalance(acc)), + d("0") + ); + + const netIncome = totalRevenue.minus(totalExpenses); + + const equityItems: ReportLineItem[] = equityAccounts.map((acc: any) => ({ accountCode: acc.accountCode || undefined, accountName: acc.name, - amount: formatCurrency(acc.currentBalance || "0"), + amount: formatCurrency(getAccountBalance(acc).abs().toString()), })); + if (netIncome.gte(0)) { + equityItems.push({ + accountName: "Net Income (Current Period)", + amount: formatCurrency(netIncome.toString()), + }); + } else { + equityItems.push({ + accountName: "Net Loss (Current Period)", + amount: `(${formatCurrency(netIncome.abs().toString())})`, + }); + } + const totalEquity = equityAccounts.reduce( - (sum, acc) => sum.plus(d(acc.currentBalance || "0")), + (sum: Decimal, acc: any) => sum.plus(getAccountBalance(acc).abs()), d("0") - ); + ).plus(netIncome); const totalLiabilitiesAndEquity = totalLiabilities.plus(totalEquity); const balanceCheck = totalAssets.eq(totalLiabilitiesAndEquity); @@ -339,20 +360,9 @@ export async function generateTrialBalance( businessId: number, asOfDate: Date ): Promise { - const db = getDb(); + const allAccounts = await getAllBusinessAccounts(businessId); - const allAccounts = await db - .select() - .from(accounts) - .where( - and( - eq(accounts.businessId, businessId), - isNull(accounts.deletedAt) - ) - ) - .orderBy(accounts.accountCode); - - const accountsWithBalance = allAccounts.filter(acc => { + const accountsWithBalance = allAccounts.filter((acc: any) => { const balance = d(acc.currentBalance || "0"); return !balance.eq(0); }); @@ -360,12 +370,14 @@ export async function generateTrialBalance( let totalDebits = d("0"); let totalCredits = d("0"); - const formattedAccounts = accountsWithBalance.map(acc => { + const formattedAccounts = accountsWithBalance.map((acc: any) => { const balance = d(acc.currentBalance || "0"); + const isDebitNormal = acc.accountType === "asset" || acc.accountType === "expense" || !acc.accountType; + let debit = "0.00"; let credit = "0.00"; - if (acc.accountType === "asset" || acc.accountType === "expense") { + if (isDebitNormal) { if (balance.gte(0)) { debit = balance.toFixed(2); totalDebits = totalDebits.plus(balance); @@ -386,7 +398,7 @@ export async function generateTrialBalance( return { accountCode: acc.accountCode || "", accountName: acc.name, - accountType: acc.accountType || "unclassified", + accountType: acc.accountType || "operational", debit, credit, }; @@ -420,7 +432,7 @@ export async function generateAssetRegister( let totalAccumulatedDepreciation = d("0"); let totalBookValue = d("0"); - const formattedAssets = fixedAssets.map(asset => { + const formattedAssets = fixedAssets.map((asset: any) => { const purchasePrice = d(asset.purchasePrice || "0"); const accumulatedDep = d(asset.accumulatedDepreciation || "0"); const bookValue = d(asset.currentBookValue || "0"); @@ -474,5 +486,5 @@ export async function generateAssetRegister( function formatCurrency(amount: string): string { const num = d(amount || "0"); - return num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return num.toFixed(2).replace(/\B(?=(?=(\d{3})+(?!\d))(?!^)(?!\())/g, ","); } diff --git a/src/pages/Businesses.tsx b/src/pages/Businesses.tsx index 365b984..f5420ee 100644 --- a/src/pages/Businesses.tsx +++ b/src/pages/Businesses.tsx @@ -8,10 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Plus, Building2, Building, Trash2, Users, CheckCircle, RotateCcw, MapPin, Edit3, Save, X, Key } from "lucide-react"; -import { toast } from "sonner"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Building2, Building, Trash2, Users, CheckCircle, RotateCcw, MapPin, Edit3, Save, X, Key, AlertTriangle, Shield, Database, Clock } from "lucide-react"; import { AllocationManagement } from "@/components/partner/AllocationManagement"; +import { toast } from "sonner"; export function Businesses() { const navigate = useNavigate(); @@ -24,8 +27,13 @@ export function Businesses() { const [editId, setEditId] = useState(null); const [form, setForm] = useState({ name: "", slug: "", address: "", phone: "", email: "", plan: "basic", isMultiLocation: true }); const [editForm, setEditForm] = useState>({}); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const [resetConfirmText, setResetConfirmText] = useState(""); const { data: businesses, refetch: refetchBusinesses } = trpc.businesses.list.useQuery(); + const { data: resetInfo, refetch: refetchResetInfo } = trpc.dashboard.resetValidation.useQuery(undefined, { + enabled: resetDialogOpen, + }); const createBiz = trpc.businesses.create.useMutation({ onSuccess: async () => { setOpen(false); setForm({ name: "", slug: "", address: "", phone: "", email: "", plan: "basic", isMultiLocation: true }); @@ -47,17 +55,29 @@ export function Businesses() { }); const resetAll = trpc.dashboard.resetAllTransactions.useMutation({ onSuccess: (data) => { - const parts: string[] = []; - if (data.results?.ledger_entries?.count) parts.push(`${data.results.ledger_entries.count} ledger entries cleared`); - if (data.results?.user_accounts?.count) parts.push(`${data.results.user_accounts.count} accounts removed`); - if (data.results?.accounts?.count) parts.push(`${data.results.accounts.count} account balances reset`); - if (data.results?.daily_sales?.count) parts.push(`${data.results.daily_sales.count} sales cleared`); - if (data.results?.expenses?.count) parts.push(`${data.results.expenses.count} expenses cleared`); - if (data.results?.mpesa_transactions?.count) parts.push(`${data.results.mpesa_transactions.count} M-PESA transactions cleared`); - toast.success(parts.length > 0 ? `Reset complete: ${parts.join(", ")}.` : data.message); + setResetDialogOpen(false); + setResetConfirmText(""); + + const summary = data.summary; + const details: string[] = []; + if (summary) { + details.push(`${summary.totalRecordsCleared} total records cleared`); + details.push(`${summary.tablesAffected.length} tables affected`); + details.push(`${summary.preservedTables.length} preserved table types untouched`); + } + + toast.success( +
+

Reset complete

+ {details.map((d, i) =>

{d}

)} +
, + { duration: 8000 }, + ); utils.invalidate(); }, - onError: (err) => toast.error(err.message), + onError: (err) => { + toast.error(`Reset failed: ${err.message}`); + }, }); const startEdit = (b: any) => { @@ -71,6 +91,27 @@ export function Businesses() { updateBiz.mutate({ id, ...editForm } as any); }; + const openResetDialog = () => { + setResetDialogOpen(true); + setResetConfirmText(""); + refetchResetInfo(); + }; + + const executeReset = () => { + if (resetConfirmText !== "RESET") { + toast.error("Please type 'RESET' to confirm"); + return; + } + resetAll.mutate(); + }; + + // Format table keys for display + const formatTableKey = (key: string): string => { + return key + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + }; + return (
@@ -180,21 +221,156 @@ export function Businesses() { - + + + + + + + + + Reset All Transactions + + + This action permanently removes all transactional data for your active business. + It cannot be undone. + + + + + {resetInfo ? ( +
+ {/* Pre-reset summary */} + + + Records to be cleared: {resetInfo.totalRecords} + + {resetInfo.validation.locationCount} location(s) affected. + {resetInfo.validation.warnings.length > 0 && ( + + Warning: {resetInfo.validation.warnings.join(", ")} + + )} + + + + {/* Tables to be reset */} +
+

+ + Records that will be cleared +

+
+ {Object.entries(resetInfo.snapshot.tableCounts) + .filter(([_, count]) => count > 0) + .sort(([, a], [, b]) => b - a) + .map(([table, count]) => ( +
+ {formatTableKey(table)} + {count} +
+ ))} + {Object.entries(resetInfo.snapshot.tableCounts).filter(([_, count]) => count > 0).length === 0 && ( +

No records found to reset.

+ )} +
+
+ + {/* Preserved tables */} +
+

+ + Records that will be preserved +

+
+ {[ + "Business profile & settings", + "User accounts & permissions", + "Locations & branches", + "Suppliers & their info", + "Employees & their info", + "Expense categories", + "System accounts (Cash, Bank, M-PESA)", + "Chart of Accounts", + "Payment methods", + "Documents & logos", + "API keys & webhooks", + "Partner allocations", + "Audit log (regulatory)", + ].map((item) => ( + + + {item} + + ))} +
+
+ + {/* Confirmation input */} +
+ + setResetConfirmText(e.target.value)} + placeholder='Type "RESET" to confirm' + className="mt-2 border-[#D32F2F]/50 focus:border-[#D32F2F]" + autoFocus + /> +
+
+ ) : ( +
+
+ +

Analyzing current data...

+
+
+ )} +
+ +
+ + +
+
+
)} {canManage && (