| import { beforeEach, describe, expect, test } from "bun:test"; |
| import { REVENUE_CATEGORIES } from "@midday/categories"; |
| import type { Database } from "../client"; |
| import { |
| getBalanceSheet, |
| getCashFlow, |
| getExpenses, |
| getProfit, |
| getRecurringExpenses, |
| getRevenue, |
| getSpending, |
| getTaxSummary, |
| } from "../queries/reports"; |
| import { inbox, invoices, transactionCategories } from "../schema"; |
|
|
| |
| const _mockGetCombinedAccountBalance = async () => { |
| return { balance: 0, currency: "GBP" }; |
| }; |
|
|
| |
| type MockTransaction = { |
| id: string; |
| teamId: string; |
| date: string; |
| name: string; |
| amount: number; |
| currency: string; |
| baseAmount: number | null; |
| baseCurrency: string | null; |
| categorySlug: string | null; |
| status: "posted" | "pending" | "excluded" | "archived" | "completed"; |
| internal: boolean; |
| taxRate: number | null; |
| taxAmount: number | null; |
| recurring: boolean | null; |
| frequency: "weekly" | "monthly" | "annually" | "irregular" | null; |
| method: string; |
| internalId: string; |
| }; |
|
|
| type MockTeam = { |
| id: string; |
| baseCurrency: string | null; |
| name: string | null; |
| }; |
|
|
| type MockCategory = { |
| id: string; |
| teamId: string; |
| slug: string | null; |
| name: string | null; |
| taxRate: number | null; |
| taxType: string | null; |
| excluded: boolean | null; |
| parentId: string | null; |
| color: string | null; |
| }; |
|
|
| |
| function createMockDatabase(mockData: { |
| transactions?: MockTransaction[]; |
| teams?: MockTeam[]; |
| categories?: MockCategory[]; |
| }): Database { |
| const data = { |
| transactions: mockData.transactions || [], |
| teams: mockData.teams || [], |
| categories: mockData.categories || [], |
| }; |
|
|
| |
| const filterTransactions = (conditions: any[]): MockTransaction[] => { |
| let filtered = [...data.transactions]; |
|
|
| for (const condition of conditions) { |
| if (!condition) continue; |
|
|
| |
| if (condition._) { |
| const op = condition._; |
| if (op === "eq") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "teamId") { |
| filtered = filtered.filter((t) => t.teamId === value); |
| } else if (field === "status") { |
| filtered = filtered.filter((t) => t.status === value); |
| } else if (field === "internal") { |
| filtered = filtered.filter((t) => t.internal === value); |
| } else if (field === "currency") { |
| filtered = filtered.filter((t) => t.currency === value); |
| } else if (field === "baseCurrency") { |
| filtered = filtered.filter((t) => t.baseCurrency === value); |
| } else if (field === "recurring") { |
| filtered = filtered.filter((t) => t.recurring === value); |
| } |
| } else if (op === "ne") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "status") { |
| filtered = filtered.filter((t) => t.status !== value); |
| } |
| } else if (op === "gt") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "baseAmount") { |
| filtered = filtered.filter((t) => (t.baseAmount ?? 0) > value); |
| } else if (field === "amount") { |
| filtered = filtered.filter((t) => t.amount > value); |
| } |
| } else if (op === "lt") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "baseAmount") { |
| filtered = filtered.filter((t) => (t.baseAmount ?? 0) < value); |
| } else if (field === "amount") { |
| filtered = filtered.filter((t) => t.amount < value); |
| } |
| } else if (op === "gte") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "date") { |
| filtered = filtered.filter((t) => t.date >= value); |
| } |
| } else if (op === "lte") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "date") { |
| filtered = filtered.filter((t) => t.date <= value); |
| } |
| } else if (op === "or") { |
| const conditions = condition.conditions || []; |
| filtered = filtered.filter((t) => |
| conditions.some((c: any) => { |
| if (c._ === "eq") { |
| const field = c.left?.name || c.left; |
| const value = c.right?.value ?? c.right; |
| if (field === "currency") return t.currency === value; |
| if (field === "baseCurrency") return t.baseCurrency === value; |
| } |
| return false; |
| }), |
| ); |
| } else if (op === "and") { |
| const conditions = condition.conditions || []; |
| for (const c of conditions) { |
| filtered = filterTransactions([c]); |
| } |
| } else if (op === "inArray") { |
| const field = condition.left?.name || condition.left; |
| const values = condition.right?.values || condition.right || []; |
| if (field === "categorySlug") { |
| filtered = filtered.filter((t) => values.includes(t.categorySlug)); |
| } |
| } else if (op === "isNull") { |
| const field = condition.left?.name || condition.left; |
| if (field === "categorySlug") { |
| filtered = filtered.filter((t) => t.categorySlug === null); |
| } |
| } else if (op === "not") { |
| const operand = condition.operand; |
| if (operand._ === "isNull") { |
| const field = operand.left?.name || operand.left; |
| if (field === "excluded") { |
| filtered = filtered.filter((t) => { |
| const cat = data.categories.find( |
| (c) => c.slug === t.categorySlug && c.teamId === t.teamId, |
| ); |
| return cat?.excluded !== true; |
| }); |
| } |
| } |
| } |
| } |
| } |
|
|
| return filtered; |
| }; |
|
|
| |
| const joinCategories = ( |
| txs: MockTransaction[], |
| ): Array<MockTransaction & { category?: MockCategory }> => { |
| return txs.map((tx) => { |
| const category = tx.categorySlug |
| ? data.categories.find( |
| (c) => c.slug === tx.categorySlug && c.teamId === tx.teamId, |
| ) |
| : undefined; |
| return { ...tx, category }; |
| }); |
| }; |
|
|
| |
| const getAmount = ( |
| tx: MockTransaction, |
| targetCurrency: string | null, |
| ): number => { |
| if ( |
| targetCurrency && |
| tx.baseCurrency === targetCurrency && |
| tx.baseAmount !== null |
| ) { |
| return tx.baseAmount; |
| } |
| return tx.amount; |
| }; |
|
|
| |
| const getNetAmount = ( |
| tx: MockTransaction, |
| targetCurrency: string | null, |
| category?: MockCategory, |
| ): number => { |
| const amount = getAmount(tx, targetCurrency); |
| const taxRate = tx.taxRate ?? category?.taxRate ?? 0; |
| return amount - (amount * taxRate) / (100 + taxRate); |
| }; |
|
|
| |
| const isCategoryQuery = (table: any) => { |
| return ( |
| table?.name === "transaction_categories" || |
| table === transactionCategories |
| ); |
| }; |
|
|
| |
| const isInvoicesQuery = (table: any) => { |
| return table?.name === "invoices" || table === invoices; |
| }; |
|
|
| |
| const isInboxQuery = (table: any) => { |
| return table?.name === "inbox" || table === inbox; |
| }; |
|
|
| |
| const filterCategories = (conditions: any[]): MockCategory[] => { |
| let filtered = [...data.categories]; |
|
|
| for (const condition of conditions) { |
| if (!condition) continue; |
|
|
| if (condition._) { |
| const op = condition._; |
| if (op === "eq") { |
| const field = condition.left?.name || condition.left; |
| const value = condition.right?.value ?? condition.right; |
| if (field === "teamId") { |
| filtered = filtered.filter((c) => c.teamId === value); |
| } else if (field === "slug") { |
| filtered = filtered.filter((c) => c.slug === value); |
| } |
| } else if (op === "isNull") { |
| const field = condition.left?.name || condition.left; |
| if (field === "parentId") { |
| filtered = filtered.filter((c) => c.parentId === null); |
| } |
| } else if (op === "and") { |
| const conditions = condition.conditions || []; |
| for (const c of conditions) { |
| filtered = filterCategories([c]); |
| } |
| } |
| } |
| } |
|
|
| return filtered; |
| }; |
|
|
| const mockDb = { |
| select: (fields: any) => { |
| return { |
| from: (table: any) => { |
| |
| if (isCategoryQuery(table)) { |
| return { |
| where: (conditions: any) => { |
| const allConditions = Array.isArray(conditions) |
| ? conditions |
| : [conditions]; |
| const filtered = filterCategories(allConditions); |
|
|
| const result = filtered.map((cat) => ({ |
| id: cat.id, |
| ...(fields.name && { name: cat.name }), |
| ...(fields.slug && { slug: cat.slug }), |
| ...(fields.color && { color: cat.color }), |
| ...(fields.taxRate && { taxRate: cat.taxRate }), |
| ...(fields.excluded && { excluded: cat.excluded }), |
| })); |
|
|
| const promise = Promise.resolve(result); |
| return Object.assign(promise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| }, |
| }; |
| } |
|
|
| |
| if (isInvoicesQuery(table)) { |
| return { |
| where: (_conditions: any) => { |
| |
| return Promise.resolve([]); |
| }, |
| }; |
| } |
|
|
| |
| if (isInboxQuery(table)) { |
| return { |
| where: (_conditions: any) => { |
| |
| return Promise.resolve([]); |
| }, |
| }; |
| } |
|
|
| |
| return { |
| innerJoin: (_joinTable: any, _joinCondition: any) => { |
| return { |
| where: (conditions: any) => { |
| const allConditions = Array.isArray(conditions) |
| ? conditions |
| : [conditions]; |
| const filtered = filterTransactions(allConditions); |
| const joined = joinCategories(filtered); |
|
|
| |
| const filteredByCategory = joined.filter((item) => { |
| if (!item.category) return false; |
| if (item.category.excluded === true) return false; |
| return true; |
| }); |
|
|
| return { |
| groupBy: (_groupBy: any) => { |
| return { |
| having: (_having: any) => { |
| |
| const grouped = new Map<string, any>(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| if (tx.amount >= 0) continue; |
| if (!tx.category) continue; |
|
|
| const key = tx.category.slug || "uncategorized"; |
| const amount = Math.abs( |
| getAmount(tx, targetCurrency), |
| ); |
|
|
| if (!grouped.has(key)) { |
| grouped.set(key, { |
| name: tx.category.name || tx.name, |
| slug: tx.category.slug || "", |
| color: tx.category.color || "#000000", |
| amount: 0, |
| }); |
| } |
|
|
| const current = grouped.get(key)!; |
| current.amount += amount; |
| } |
|
|
| const result = Array.from(grouped.values()); |
| |
| return Promise.resolve(result); |
| }, |
| }; |
| }, |
| }; |
| }, |
| }; |
| }, |
| leftJoin: (_joinTable: any, _joinCondition: any) => { |
| return { |
| where: (conditions: any) => { |
| const allConditions = Array.isArray(conditions) |
| ? conditions |
| : [conditions]; |
| const filtered = filterTransactions(allConditions); |
| const joined = joinCategories(filtered); |
|
|
| |
| const filteredByCategory = joined.filter((item) => { |
| if (item.category?.excluded === true) return false; |
| return true; |
| }); |
|
|
| return { |
| groupBy: (_groupBy: any) => { |
| |
| |
| if (fields.name && fields.amount && fields.count) { |
| return { |
| orderBy: (_orderBy: any) => { |
| const grouped = new Map<string, any>(); |
| const targetCurrency = "GBP"; |
|
|
| |
| for (const tx of filteredByCategory) { |
| if (tx.amount >= 0) continue; |
| if (!tx.recurring) continue; |
|
|
| const key = `${tx.name}-${tx.frequency || ""}`; |
| const amount = Math.abs( |
| getAmount(tx, targetCurrency), |
| ); |
|
|
| if (!grouped.has(key)) { |
| grouped.set(key, { |
| name: tx.name, |
| frequency: tx.frequency || null, |
| categoryName: tx.category?.name || null, |
| categorySlug: tx.categorySlug || null, |
| amount: 0, |
| count: 0, |
| lastDate: tx.date, |
| }); |
| } |
|
|
| const current = grouped.get(key)!; |
| current.amount += amount; |
| current.count += 1; |
| if (tx.date > current.lastDate) { |
| current.lastDate = tx.date; |
| } |
| } |
|
|
| |
| const result = Array.from(grouped.values()).sort( |
| (a, b) => b.amount - a.amount, |
| ); |
|
|
| return { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }; |
| }, |
| }; |
| } |
|
|
| |
| if (fields.categorySlug && !fields.month) { |
| const grouped = new Map<string, any>(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| const key = tx.categorySlug || ""; |
| const amount = getAmount(tx, targetCurrency); |
|
|
| if (!grouped.has(key)) { |
| grouped.set(key, { |
| categorySlug: tx.categorySlug || null, |
| categoryName: tx.category?.name || null, |
| amount: 0, |
| }); |
| } |
|
|
| const current = grouped.get(key)!; |
| current.amount += amount; |
| } |
|
|
| const result = Array.from(grouped.values()); |
| const promise = Promise.resolve(result); |
| |
| return Object.assign(promise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| } |
|
|
| return { |
| orderBy: (_orderBy: any) => { |
| |
| if (fields.month && fields.value) { |
| const grouped = new Map<string, number>(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| |
| |
| if ( |
| !REVENUE_CATEGORIES.includes( |
| tx.categorySlug as (typeof REVENUE_CATEGORIES)[number], |
| ) || |
| tx.amount <= 0 |
| ) { |
| continue; |
| } |
|
|
| const month = `${tx.date.substring(0, 7)}-01`; |
| let value = 0; |
|
|
| |
| const isNet = fields.value |
| .toString() |
| .includes("taxRate"); |
| if (isNet) { |
| value = getNetAmount( |
| tx, |
| targetCurrency, |
| tx.category, |
| ); |
| } else { |
| value = getAmount(tx, targetCurrency); |
| } |
|
|
| const current = grouped.get(month) || 0; |
| grouped.set(month, current + value); |
| } |
|
|
| const result = Array.from(grouped.entries()) |
| .map(([month, value]) => ({ |
| month, |
| value: value.toString(), |
| })) |
| .sort((a, b) => a.month.localeCompare(b.month)); |
|
|
| const promise = Promise.resolve(result); |
| return Object.assign(promise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| } |
|
|
| |
| if ( |
| fields.month && |
| fields.value && |
| fields.recurringValue !== undefined |
| ) { |
| const grouped = new Map< |
| string, |
| { value: number; recurringValue: number } |
| >(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| if (tx.amount >= 0) continue; |
|
|
| const month = `${tx.date.substring(0, 7)}-01`; |
| const amount = Math.abs( |
| getAmount(tx, targetCurrency), |
| ); |
|
|
| if (!grouped.has(month)) { |
| grouped.set(month, { |
| value: 0, |
| recurringValue: 0, |
| }); |
| } |
|
|
| const monthData = grouped.get(month)!; |
| monthData.value += amount; |
| if (tx.recurring === true) { |
| monthData.recurringValue += amount; |
| } |
| } |
|
|
| const result = Array.from(grouped.entries()) |
| .map(([month, data]) => ({ |
| month, |
| value: data.value.toString(), |
| recurringValue: data.recurringValue.toString(), |
| })) |
| .sort((a, b) => a.month.localeCompare(b.month)); |
|
|
| return Promise.resolve(result); |
| } |
|
|
| |
| if (fields.income && fields.expenses) { |
| const grouped = new Map< |
| string, |
| { income: number; expenses: number } |
| >(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| const month = `${tx.date.substring(0, 7)}-01`; |
| const amount = getAmount(tx, targetCurrency); |
|
|
| if (!grouped.has(month)) { |
| grouped.set(month, { income: 0, expenses: 0 }); |
| } |
|
|
| const monthData = grouped.get(month)!; |
| if (amount > 0) { |
| monthData.income += amount; |
| } else { |
| monthData.expenses += Math.abs(amount); |
| } |
| } |
|
|
| const result = Array.from(grouped.entries()) |
| .map(([month, data]) => ({ |
| month, |
| income: data.income.toString(), |
| expenses: data.expenses.toString(), |
| })) |
| .sort((a, b) => a.month.localeCompare(b.month)); |
|
|
| return Promise.resolve(result); |
| } |
|
|
| |
| if (fields.categorySlug) { |
| const grouped = new Map<string, any>(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of filteredByCategory) { |
| const key = tx.categorySlug || ""; |
| const amount = getAmount(tx, targetCurrency); |
|
|
| if (!grouped.has(key)) { |
| grouped.set(key, { |
| categorySlug: tx.categorySlug || null, |
| categoryName: tx.category?.name || null, |
| amount: 0, |
| }); |
| } |
|
|
| const current = grouped.get(key)!; |
| current.amount += amount; |
| } |
|
|
| const result = Array.from(grouped.values()); |
| |
| const promise = Promise.resolve(result); |
| return Object.assign(promise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| } |
|
|
| |
| if ( |
| fields.amount && |
| !fields.month && |
| !fields.income && |
| !fields.categorySlug && |
| !fields.name && |
| !fields.categoryName |
| ) { |
| const targetCurrency = "GBP"; |
| let total = 0; |
|
|
| for (const tx of filteredByCategory) { |
| const amount = getAmount(tx, targetCurrency); |
| total += amount; |
| } |
|
|
| const result = [{ amount: total.toString() }]; |
| return Promise.resolve(result); |
| } |
|
|
| |
| const result = filteredByCategory; |
| const promise = Promise.resolve(result); |
| return Object.assign(promise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| }, |
| }; |
| }, |
| }; |
| }, |
| }; |
| }, |
| where: (conditions: any) => { |
| const allConditions = Array.isArray(conditions) |
| ? conditions |
| : [conditions]; |
| const filtered = filterTransactions(allConditions); |
| const joined = joinCategories(filtered); |
|
|
| return { |
| groupBy: (_groupBy: any) => { |
| return { |
| orderBy: (_orderBy: any) => { |
| if (fields.month && fields.value) { |
| const grouped = new Map<string, number>(); |
| const targetCurrency = "GBP"; |
|
|
| for (const tx of joined) { |
| const month = `${tx.date.substring(0, 7)}-01`; |
| const amount = getAmount(tx, targetCurrency); |
| const current = grouped.get(month) || 0; |
| grouped.set(month, current + Math.abs(amount)); |
| } |
|
|
| const result = Array.from(grouped.entries()) |
| .map(([month, value]) => ({ |
| month, |
| value: value.toString(), |
| })) |
| .sort((a, b) => a.month.localeCompare(b.month)); |
|
|
| const resultPromise = Promise.resolve(result); |
| return Object.assign(resultPromise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| } |
|
|
| const result = joined; |
| const resultPromise = Promise.resolve(result); |
| return Object.assign(resultPromise, { |
| limit: (limit: number) => |
| Promise.resolve(result.slice(0, limit)), |
| }); |
| }, |
| }; |
| }, |
| limit: (limit: number) => |
| Promise.resolve(joined.slice(0, limit)), |
| }; |
| }, |
| }; |
| }, |
| }; |
| }, |
| query: { |
| teams: { |
| findFirst: async (options: any) => { |
| const team = data.teams.find((t) => { |
| if (options?.where) { |
| const where = options.where; |
| if (where.id?.value) { |
| return t.id === where.id.value; |
| } |
| } |
| return true; |
| }); |
| return team ? { baseCurrency: team.baseCurrency } : null; |
| }, |
| }, |
| bankAccounts: { |
| findMany: async () => Promise.resolve([]), |
| }, |
| }, |
| executeOnReplica: async (_query: any) => { |
| |
| |
| return Promise.resolve([]); |
| }, |
| } as any; |
|
|
| return mockDb as Database; |
| } |
|
|
| |
| const createTestTransactions = (): MockTransaction[] => [ |
| { |
| id: "tx-1", |
| teamId: "team-1", |
| date: "2024-08-01", |
| name: "Invoice Payment", |
| amount: 1000, |
| currency: "GBP", |
| baseAmount: 1000, |
| baseCurrency: "GBP", |
| categorySlug: "revenue", |
| status: "posted", |
| internal: false, |
| taxRate: 20, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-1", |
| }, |
| { |
| id: "tx-2", |
| teamId: "team-1", |
| date: "2024-08-15", |
| name: "USD Invoice", |
| amount: 1200, |
| currency: "USD", |
| baseAmount: 1000, |
| baseCurrency: "GBP", |
| categorySlug: "revenue", |
| status: "posted", |
| internal: false, |
| taxRate: 20, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-2", |
| }, |
| { |
| id: "tx-3", |
| teamId: "team-1", |
| date: "2024-08-20", |
| name: "USD Invoice No Conversion", |
| amount: 500, |
| currency: "USD", |
| baseAmount: null, |
| baseCurrency: "GBP", |
| categorySlug: "revenue", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-3", |
| }, |
| { |
| id: "tx-4", |
| teamId: "team-1", |
| date: "2024-08-05", |
| name: "Office Supplies", |
| amount: -200, |
| currency: "GBP", |
| baseAmount: -200, |
| baseCurrency: "GBP", |
| categorySlug: "office-supplies", |
| status: "posted", |
| internal: false, |
| taxRate: 20, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-4", |
| }, |
| { |
| id: "tx-5", |
| teamId: "team-1", |
| date: "2024-08-10", |
| name: "USD Expense", |
| amount: -240, |
| currency: "USD", |
| baseAmount: -200, |
| baseCurrency: "GBP", |
| categorySlug: "travel", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-5", |
| }, |
| { |
| id: "tx-6", |
| teamId: "team-1", |
| date: "2024-08-12", |
| name: "Cost of Goods", |
| amount: -300, |
| currency: "GBP", |
| baseAmount: -300, |
| baseCurrency: "GBP", |
| categorySlug: "cost-of-goods-sold", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "int-6", |
| }, |
| { |
| id: "tx-7", |
| teamId: "team-1", |
| date: "2024-08-01", |
| name: "Monthly Subscription", |
| amount: -50, |
| currency: "GBP", |
| baseAmount: -50, |
| baseCurrency: "GBP", |
| categorySlug: "software", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: true, |
| frequency: "monthly", |
| method: "card_purchase", |
| internalId: "int-7", |
| }, |
| |
| { |
| id: "tx-8", |
| teamId: "team-1", |
| date: "2024-08-15", |
| name: "Credit Card Payment", |
| amount: -1000, |
| currency: "GBP", |
| baseAmount: -1000, |
| baseCurrency: "GBP", |
| categorySlug: "credit-card-payment", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "transfer", |
| internalId: "int-8", |
| }, |
| { |
| id: "tx-9", |
| teamId: "team-1", |
| date: "2024-08-20", |
| name: "Transfer to Savings", |
| amount: -5000, |
| currency: "GBP", |
| baseAmount: -5000, |
| baseCurrency: "GBP", |
| categorySlug: "internal-transfer", |
| status: "posted", |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "transfer", |
| internalId: "int-9", |
| }, |
| ]; |
|
|
| const createTestTeams = (): MockTeam[] => [ |
| { |
| id: "team-1", |
| baseCurrency: "GBP", |
| name: "Test Team", |
| }, |
| ]; |
|
|
| const createTestCategories = (): MockCategory[] => [ |
| { |
| id: "cat-1", |
| teamId: "team-1", |
| slug: "revenue", |
| name: "Revenue", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#22c55e", |
| }, |
| { |
| id: "cat-2", |
| teamId: "team-1", |
| slug: "office-supplies", |
| name: "Office Supplies", |
| taxRate: 20, |
| taxType: "vat", |
| excluded: false, |
| parentId: null, |
| color: "#3b82f6", |
| }, |
| { |
| id: "cat-3", |
| teamId: "team-1", |
| slug: "cost-of-goods-sold", |
| name: "Cost of Goods Sold", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#ef4444", |
| }, |
| { |
| id: "cat-4", |
| teamId: "team-1", |
| slug: "travel", |
| name: "Travel", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#f59e0b", |
| }, |
| { |
| id: "cat-5", |
| teamId: "team-1", |
| slug: "software", |
| name: "Software", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#8b5cf6", |
| }, |
| |
| { |
| id: "cat-6", |
| teamId: "team-1", |
| slug: "credit-card-payment", |
| name: "Credit Card Payment", |
| taxRate: null, |
| taxType: null, |
| excluded: true, |
| parentId: null, |
| color: "#6b7280", |
| }, |
| { |
| id: "cat-7", |
| teamId: "team-1", |
| slug: "internal-transfer", |
| name: "Internal Transfer", |
| taxRate: null, |
| taxType: null, |
| excluded: true, |
| parentId: null, |
| color: "#6b7280", |
| }, |
| ]; |
|
|
| describe("Report Calculations", () => { |
| let mockDb: Database; |
| let testTransactions: MockTransaction[]; |
| let testTeams: MockTeam[]; |
| let testCategories: MockCategory[]; |
|
|
| beforeEach(() => { |
| testTransactions = createTestTransactions(); |
| testTeams = createTestTeams(); |
| testCategories = createTestCategories(); |
| mockDb = createMockDatabase({ |
| transactions: testTransactions, |
| teams: testTeams, |
| categories: testCategories, |
| }); |
| }); |
|
|
| describe("getRevenue", () => { |
| test("should calculate gross revenue with single currency", async () => { |
| const result = await getRevenue(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "gross", |
| }); |
|
|
| expect(result).toBeArray(); |
| expect(result.length).toBeGreaterThan(0); |
|
|
| const augustData = result.find((r) => r.date.startsWith("2024-08")); |
| expect(augustData).toBeDefined(); |
| if (augustData) { |
| const value = Number.parseFloat(augustData.value); |
| expect(value).toBeGreaterThan(0); |
| } |
| }); |
|
|
| test("should calculate gross revenue with currency conversion", async () => { |
| const result = await getRevenue(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "gross", |
| }); |
|
|
| const augustData = result.find((r) => r.date.startsWith("2024-08")); |
| expect(augustData).toBeDefined(); |
| if (augustData) { |
| |
| const value = Number.parseFloat(augustData.value); |
| expect(value).toBeGreaterThanOrEqual(2000); |
| } |
| }); |
|
|
| test("should handle NULL baseAmount fallback", async () => { |
| const result = await getRevenue(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "gross", |
| }); |
|
|
| expect(result).toBeArray(); |
| }); |
|
|
| test("should calculate net revenue with tax", async () => { |
| const result = await getRevenue(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "net", |
| }); |
|
|
| expect(result).toBeArray(); |
| const augustData = result.find((r) => r.date.startsWith("2024-08")); |
| if (augustData) { |
| const netValue = Number.parseFloat(augustData.value); |
| |
| |
| |
| expect(netValue).toBeGreaterThan(0); |
| expect(netValue).toBeLessThan(3500); |
| } |
| }); |
|
|
| test("should filter by currency OR baseCurrency", async () => { |
| const result = await getRevenue(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "gross", |
| }); |
|
|
| expect(result).toBeArray(); |
| const augustData = result.find((r) => r.date.startsWith("2024-08")); |
| expect(augustData).toBeDefined(); |
| }); |
| }); |
|
|
| describe("getProfit", () => { |
| test("should calculate net profit correctly", async () => { |
| const result = await getProfit(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "net", |
| }); |
|
|
| expect(result).toBeArray(); |
| }); |
|
|
| test("should separate COGS from operating expenses", async () => { |
| const result = await getProfit(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "net", |
| }); |
|
|
| expect(result).toBeArray(); |
| }); |
|
|
| test("should handle currency conversion in expenses", async () => { |
| const result = await getProfit(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| revenueType: "net", |
| }); |
|
|
| expect(result).toBeArray(); |
| }); |
| }); |
|
|
| describe("getExpenses", () => { |
| test("should aggregate regular expenses", async () => { |
| const result = await getExpenses(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.result).toBeArray(); |
| const augustData = result.result.find((r) => |
| r.date.startsWith("2024-08"), |
| ); |
| expect(augustData).toBeDefined(); |
| }); |
|
|
| test("should separate recurring from non-recurring expenses", async () => { |
| const result = await getExpenses(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.result).toBeArray(); |
| const augustData = result.result.find((r) => |
| r.date.startsWith("2024-08"), |
| ); |
| if (augustData) { |
| expect(augustData.recurring).toBeDefined(); |
| |
| expect(augustData.recurring).toBeGreaterThanOrEqual(0); |
| } |
| }); |
|
|
| test("should handle currency conversion in expenses", async () => { |
| const result = await getExpenses(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.result).toBeArray(); |
| }); |
| }); |
|
|
| describe("getCashFlow", () => { |
| test("should calculate income correctly", async () => { |
| const result = await getCashFlow(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.summary).toBeDefined(); |
| |
| expect(result.summary.totalIncome).toBeGreaterThanOrEqual(0); |
| }); |
|
|
| test("should calculate expenses correctly", async () => { |
| const result = await getCashFlow(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| |
| expect(result.summary.totalExpenses).toBeGreaterThanOrEqual(0); |
| }); |
|
|
| test("should calculate net cash flow", async () => { |
| const result = await getCashFlow(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| const netCashFlow = |
| result.summary.totalIncome - result.summary.totalExpenses; |
| expect(result.summary.netCashFlow).toBe(netCashFlow); |
| }); |
| }); |
|
|
| describe("getTaxSummary", () => { |
| test("should calculate tax amounts correctly", async () => { |
| const result = await getTaxSummary(mockDb, { |
| teamId: "team-1", |
| type: "collected", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| |
| expect(result).toBeDefined(); |
| expect(result.summary).toBeDefined(); |
| expect(result.result).toBeArray(); |
| }); |
| }); |
|
|
| describe("getSpending", () => { |
| test("should aggregate spending by category", async () => { |
| const result = await getSpending(mockDb, { |
| teamId: "team-1", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| currency: "GBP", |
| }); |
|
|
| expect(result).toBeArray(); |
| |
| expect(result.length).toBeGreaterThan(0); |
|
|
| |
| for (const item of result) { |
| expect(item.name).toBeDefined(); |
| expect(item.slug).toBeDefined(); |
| expect(item.amount).toBeGreaterThan(0); |
| expect(item.percentage).toBeDefined(); |
| } |
| }); |
| }); |
|
|
| describe("getRecurringExpenses", () => { |
| test("should aggregate recurring expenses", async () => { |
| const result = await getRecurringExpenses(mockDb, { |
| teamId: "team-1", |
| currency: "GBP", |
| from: "2024-08-01", |
| to: "2024-08-31", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.expenses).toBeArray(); |
| |
| expect(result.expenses.length).toBeGreaterThan(0); |
|
|
| |
| const expense = result.expenses[0]!; |
| expect(expense.name).toBe("Monthly Subscription"); |
| expect(expense.frequency).toBe("monthly"); |
| expect(expense.amount).toBe(50); |
| }); |
| }); |
|
|
| describe("getBalanceSheet", () => { |
| |
| |
| |
| test.skip("should calculate balance sheet correctly (integration)", async () => { |
| const result = await getBalanceSheet(mockDb, { |
| teamId: "team-1", |
| currency: "GBP", |
| asOf: "2024-08-31", |
| }); |
|
|
| expect(result).toBeDefined(); |
| expect(result.assets).toBeDefined(); |
| expect(result.liabilities).toBeDefined(); |
| expect(result.equity).toBeDefined(); |
| }); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| describe("Balance Sheet Calculation Logic", () => { |
| |
| const mockBankAccounts = [ |
| { |
| id: "bs-acc-1", |
| name: "Operating Account", |
| type: "depository", |
| balance: 50000, |
| currency: "USD", |
| enabled: true, |
| }, |
| { |
| id: "bs-acc-2", |
| name: "Treasury Account", |
| type: "other_asset", |
| balance: 200000, |
| currency: "USD", |
| enabled: true, |
| }, |
| { |
| id: "bs-acc-3", |
| name: "Credit Card", |
| type: "credit", |
| balance: 15000, |
| currency: "USD", |
| enabled: true, |
| }, |
| { |
| id: "bs-acc-4", |
| name: "Business Loan", |
| type: "loan", |
| balance: 100000, |
| currency: "USD", |
| enabled: true, |
| }, |
| ]; |
|
|
| |
| const mockUnpaidInvoices = [ |
| { id: "inv-1", amount: 5000, currency: "USD", status: "unpaid" }, |
| { id: "inv-2", amount: 3000, currency: "USD", status: "overdue" }, |
| ]; |
|
|
| |
| const mockAssetTransactions = [ |
| { categorySlug: "prepaid-expenses", amount: -2000 }, |
| { categorySlug: "fixed-assets", amount: -10000 }, |
| { categorySlug: "inventory", amount: -5000 }, |
| ]; |
|
|
| const mockLiabilityTransactions = [ |
| { categorySlug: "loan-proceeds", amount: 100000 }, |
| { categorySlug: "loan-principal-repayment", amount: -20000 }, |
| { categorySlug: "deferred-revenue", amount: 8000 }, |
| ]; |
|
|
| const CASH_ACCOUNT_TYPES = ["depository", "other_asset"]; |
| const DEBT_ACCOUNT_TYPES = ["credit", "loan"]; |
|
|
| test("cash balance should include depository and other_asset accounts", () => { |
| const cashAccounts = mockBankAccounts.filter((acc) => |
| CASH_ACCOUNT_TYPES.includes(acc.type), |
| ); |
|
|
| const totalCash = cashAccounts.reduce((sum, acc) => sum + acc.balance, 0); |
|
|
| |
| expect(totalCash).toBe(250000); |
| }); |
|
|
| test("accounts receivable should sum unpaid invoices", () => { |
| const accountsReceivable = mockUnpaidInvoices.reduce( |
| (sum, inv) => sum + inv.amount, |
| 0, |
| ); |
|
|
| |
| expect(accountsReceivable).toBe(8000); |
| }); |
|
|
| test("prepaid expenses should be treated as assets", () => { |
| |
| const prepaid = mockAssetTransactions.find( |
| (t) => t.categorySlug === "prepaid-expenses", |
| ); |
|
|
| |
| const prepaidAssetValue = Math.abs(prepaid!.amount); |
| expect(prepaidAssetValue).toBe(2000); |
| }); |
|
|
| test("fixed assets should be calculated from purchase transactions", () => { |
| const fixedAssets = mockAssetTransactions.find( |
| (t) => t.categorySlug === "fixed-assets", |
| ); |
|
|
| const fixedAssetValue = Math.abs(fixedAssets!.amount); |
| expect(fixedAssetValue).toBe(10000); |
| }); |
|
|
| test("total assets calculation", () => { |
| |
| const cash = 250000; |
| |
| const ar = 8000; |
| |
| const otherAssets = 2000 + 10000 + 5000; |
|
|
| const totalAssets = cash + ar + otherAssets; |
|
|
| expect(totalAssets).toBe(275000); |
| }); |
|
|
| test("credit card debt should be included in liabilities", () => { |
| const creditAccounts = mockBankAccounts.filter( |
| (acc) => acc.type === "credit", |
| ); |
|
|
| const creditDebt = creditAccounts.reduce( |
| (sum, acc) => sum + Math.abs(acc.balance), |
| 0, |
| ); |
|
|
| expect(creditDebt).toBe(15000); |
| }); |
|
|
| test("loan balance should be calculated from proceeds minus repayments", () => { |
| |
| const loanProceeds = |
| mockLiabilityTransactions.find((t) => t.categorySlug === "loan-proceeds") |
| ?.amount || 0; |
|
|
| const loanRepayments = Math.abs( |
| mockLiabilityTransactions.find( |
| (t) => t.categorySlug === "loan-principal-repayment", |
| )?.amount || 0, |
| ); |
|
|
| const outstandingLoan = loanProceeds - loanRepayments; |
|
|
| |
| expect(outstandingLoan).toBe(80000); |
| }); |
|
|
| test("deferred revenue should be included in liabilities", () => { |
| const deferredRevenue = mockLiabilityTransactions.find( |
| (t) => t.categorySlug === "deferred-revenue", |
| )!.amount; |
|
|
| |
| expect(deferredRevenue).toBe(8000); |
| }); |
|
|
| test("total liabilities calculation", () => { |
| |
| const creditDebt = 15000; |
| |
| const outstandingLoan = 80000; |
| |
| const deferredRevenue = 8000; |
|
|
| const totalLiabilities = creditDebt + outstandingLoan + deferredRevenue; |
|
|
| expect(totalLiabilities).toBe(103000); |
| }); |
|
|
| test("equity = total assets - total liabilities", () => { |
| const totalAssets = 275000; |
| const totalLiabilities = 103000; |
|
|
| const equity = totalAssets - totalLiabilities; |
|
|
| |
| expect(equity).toBe(172000); |
| }); |
|
|
| test("balance sheet should balance (assets = liabilities + equity)", () => { |
| const totalAssets = 275000; |
| const totalLiabilities = 103000; |
| const equity = 172000; |
|
|
| |
| expect(totalAssets).toBe(totalLiabilities + equity); |
| }); |
|
|
| test("negative equity indicates liabilities exceed assets", () => { |
| const smallAssets = 50000; |
| const largeLiabilities = 150000; |
|
|
| const equity = smallAssets - largeLiabilities; |
|
|
| expect(equity).toBe(-100000); |
| expect(equity).toBeLessThan(0); |
| }); |
|
|
| test("balance sheet should use cash accounts not credit accounts for cash", () => { |
| |
| const allAccounts = mockBankAccounts; |
|
|
| |
| const buggyTotal = allAccounts.reduce((sum, acc) => sum + acc.balance, 0); |
|
|
| |
| const correctCash = allAccounts |
| .filter((acc) => CASH_ACCOUNT_TYPES.includes(acc.type)) |
| .reduce((sum, acc) => sum + acc.balance, 0); |
|
|
| |
| expect(buggyTotal).toBe(365000); |
|
|
| |
| expect(correctCash).toBe(250000); |
|
|
| |
| expect(buggyTotal - correctCash).toBe(115000); |
| }); |
|
|
| test("loan accounts should be liabilities not assets", () => { |
| const loanAccount = mockBankAccounts.find((acc) => acc.type === "loan")!; |
|
|
| |
| expect(DEBT_ACCOUNT_TYPES).toContain(loanAccount.type); |
| expect(CASH_ACCOUNT_TYPES).not.toContain(loanAccount.type); |
|
|
| |
| const cashAccounts = mockBankAccounts.filter((acc) => |
| CASH_ACCOUNT_TYPES.includes(acc.type), |
| ); |
|
|
| expect(cashAccounts.map((a) => a.name)).not.toContain("Business Loan"); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| describe("Category Exclusion Logic", () => { |
| |
| const exclusionTestTransactions = [ |
| |
| { |
| id: "ex-1", |
| teamId: "team-1", |
| date: "2024-08-01", |
| name: "Software Purchase", |
| amount: -500, |
| currency: "GBP", |
| baseAmount: -500, |
| baseCurrency: "GBP", |
| categorySlug: "software", |
| status: "posted" as const, |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "ex-1", |
| }, |
| |
| { |
| id: "ex-2", |
| teamId: "team-1", |
| date: "2024-08-15", |
| name: "Credit Card Payment", |
| amount: -500, |
| currency: "GBP", |
| baseAmount: -500, |
| baseCurrency: "GBP", |
| categorySlug: "credit-card-payment", |
| status: "posted" as const, |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "transfer", |
| internalId: "ex-2", |
| }, |
| |
| { |
| id: "ex-3", |
| teamId: "team-1", |
| date: "2024-08-20", |
| name: "Transfer to Savings", |
| amount: -1000, |
| currency: "GBP", |
| baseAmount: -1000, |
| baseCurrency: "GBP", |
| categorySlug: "internal-transfer", |
| status: "posted" as const, |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "transfer", |
| internalId: "ex-3", |
| }, |
| |
| { |
| id: "ex-4", |
| teamId: "team-1", |
| date: "2024-08-25", |
| name: "Office Supplies", |
| amount: -200, |
| currency: "GBP", |
| baseAmount: -200, |
| baseCurrency: "GBP", |
| categorySlug: "office-supplies", |
| status: "posted" as const, |
| internal: false, |
| taxRate: null, |
| taxAmount: null, |
| recurring: false, |
| frequency: null, |
| method: "card_purchase", |
| internalId: "ex-4", |
| }, |
| ]; |
|
|
| const exclusionCategories = [ |
| { |
| id: "exc-1", |
| teamId: "team-1", |
| slug: "software", |
| name: "Software", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#8b5cf6", |
| }, |
| { |
| id: "exc-2", |
| teamId: "team-1", |
| slug: "office-supplies", |
| name: "Office Supplies", |
| taxRate: null, |
| taxType: null, |
| excluded: false, |
| parentId: null, |
| color: "#3b82f6", |
| }, |
| { |
| id: "exc-3", |
| teamId: "team-1", |
| slug: "credit-card-payment", |
| name: "Credit Card Payment", |
| taxRate: null, |
| taxType: null, |
| excluded: true, |
| parentId: null, |
| color: "#6b7280", |
| }, |
| { |
| id: "exc-4", |
| teamId: "team-1", |
| slug: "internal-transfer", |
| name: "Internal Transfer", |
| taxRate: null, |
| taxType: null, |
| excluded: true, |
| parentId: null, |
| color: "#6b7280", |
| }, |
| ]; |
|
|
| test("excluded categories should be identified correctly", () => { |
| const excludedSlugs = exclusionCategories |
| .filter((c) => c.excluded === true) |
| .map((c) => c.slug); |
|
|
| expect(excludedSlugs).toContain("credit-card-payment"); |
| expect(excludedSlugs).toContain("internal-transfer"); |
| expect(excludedSlugs).not.toContain("software"); |
| expect(excludedSlugs).not.toContain("office-supplies"); |
| }); |
|
|
| test("expense calculation should exclude credit-card-payment transactions", () => { |
| |
| const includedExpenses = exclusionTestTransactions.filter((tx) => { |
| if (tx.amount >= 0) return false; |
| const category = exclusionCategories.find( |
| (c) => c.slug === tx.categorySlug, |
| ); |
| if (category?.excluded) return false; |
| return true; |
| }); |
|
|
| |
| expect(includedExpenses.map((t) => t.name)).not.toContain( |
| "Credit Card Payment", |
| ); |
|
|
| |
| expect(includedExpenses.map((t) => t.name)).toContain("Software Purchase"); |
| expect(includedExpenses.map((t) => t.name)).toContain("Office Supplies"); |
| }); |
|
|
| test("expense calculation should exclude internal-transfer transactions", () => { |
| const includedExpenses = exclusionTestTransactions.filter((tx) => { |
| if (tx.amount >= 0) return false; |
| const category = exclusionCategories.find( |
| (c) => c.slug === tx.categorySlug, |
| ); |
| if (category?.excluded) return false; |
| return true; |
| }); |
|
|
| expect(includedExpenses.map((t) => t.name)).not.toContain( |
| "Transfer to Savings", |
| ); |
| }); |
|
|
| test("total expenses should only include non-excluded categories", () => { |
| const includedExpenses = exclusionTestTransactions.filter((tx) => { |
| if (tx.amount >= 0) return false; |
| const category = exclusionCategories.find( |
| (c) => c.slug === tx.categorySlug, |
| ); |
| if (category?.excluded) return false; |
| return true; |
| }); |
|
|
| const totalExpenses = includedExpenses.reduce( |
| (sum, tx) => sum + Math.abs(tx.amount), |
| 0, |
| ); |
|
|
| |
| |
| expect(totalExpenses).toBe(700); |
| }); |
|
|
| test("without exclusion logic, expenses would be double-counted", () => { |
| |
| const allExpenses = exclusionTestTransactions.filter((tx) => tx.amount < 0); |
|
|
| const buggyTotal = allExpenses.reduce( |
| (sum, tx) => sum + Math.abs(tx.amount), |
| 0, |
| ); |
|
|
| |
| expect(buggyTotal).toBe(2200); |
|
|
| |
| const correctTotal = 700; |
|
|
| |
| expect(buggyTotal - correctTotal).toBe(1500); |
| }); |
|
|
| test("excluded flag must be explicitly true to exclude", () => { |
| |
| const regularCategory = exclusionCategories.find( |
| (c) => c.slug === "software", |
| )!; |
|
|
| expect(regularCategory.excluded).toBe(false); |
|
|
| |
| const softwareTx = exclusionTestTransactions.find( |
| (t) => t.categorySlug === "software", |
| )!; |
|
|
| const shouldInclude = !exclusionCategories.find( |
| (c) => c.slug === softwareTx.categorySlug, |
| )?.excluded; |
|
|
| expect(shouldInclude).toBe(true); |
| }); |
|
|
| test("transactions without category should be included (null categorySlug)", () => { |
| const uncategorizedTx = { |
| ...exclusionTestTransactions[0], |
| categorySlug: null, |
| }; |
|
|
| |
| const category = exclusionCategories.find( |
| (c) => c.slug === uncategorizedTx.categorySlug, |
| ); |
|
|
| |
| expect(category).toBeUndefined(); |
|
|
| |
| const shouldInclude = !category?.excluded; |
| expect(shouldInclude).toBe(true); |
| }); |
|
|
| test("burn rate should not include excluded categories", () => { |
| |
| const validExpenses = exclusionTestTransactions.filter((tx) => { |
| if (tx.amount >= 0) return false; |
| const category = exclusionCategories.find( |
| (c) => c.slug === tx.categorySlug, |
| ); |
| if (category?.excluded) return false; |
| return true; |
| }); |
|
|
| const burnRate = validExpenses.reduce( |
| (sum, tx) => sum + Math.abs(tx.amount), |
| 0, |
| ); |
|
|
| |
| |
| expect(burnRate).toBe(700); |
| }); |
|
|
| test("runway calculation with correct burn rate", () => { |
| const cashBalance = 7000; |
| const correctBurnRate = 700; |
| const buggyBurnRate = 2200; |
|
|
| const correctRunway = Math.round(cashBalance / correctBurnRate); |
| const buggyRunway = Math.round(cashBalance / buggyBurnRate); |
|
|
| |
| |
| expect(correctRunway).toBe(10); |
| expect(buggyRunway).toBe(3); |
|
|
| |
| expect((buggyRunway / correctRunway) * 100).toBe(30); |
| }); |
| }); |
|
|