| import { |
| differenceInDays, |
| endOfQuarter, |
| format, |
| getQuarter, |
| startOfQuarter, |
| } from "date-fns"; |
| import { |
| and, |
| count, |
| desc, |
| eq, |
| gte, |
| isNotNull, |
| isNull, |
| lte, |
| sql, |
| } from "drizzle-orm"; |
| import type { Database, DatabaseOrTransaction } from "../client"; |
| import { |
| bankAccounts, |
| bankConnections, |
| customers, |
| type ExpenseAnomaly, |
| type InsightActivity, |
| type InsightAnomaly, |
| type InsightContent, |
| type InsightMetric, |
| type InsightMilestone, |
| type InsightPredictions, |
| inbox, |
| type insightPeriodTypeEnum, |
| type insightStatusEnum, |
| insights, |
| insightUserStatus, |
| invoices, |
| trackerEntries, |
| trackerProjects, |
| transactions, |
| } from "../schema"; |
|
|
| |
| |
| |
| export type Insight = typeof insights.$inferSelect; |
|
|
| export type CreateInsightParams = { |
| teamId: string; |
| periodType: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| periodStart: Date; |
| periodEnd: Date; |
| periodYear: number; |
| periodNumber: number; |
| currency: string; |
| }; |
|
|
| |
| |
| |
| export async function createInsight( |
| db: DatabaseOrTransaction, |
| params: CreateInsightParams, |
| ) { |
| const [result] = await db |
| .insert(insights) |
| .values({ |
| teamId: params.teamId, |
| periodType: params.periodType, |
| periodStart: params.periodStart, |
| periodEnd: params.periodEnd, |
| periodYear: params.periodYear, |
| periodNumber: params.periodNumber, |
| currency: params.currency, |
| status: "pending", |
| }) |
| .onConflictDoNothing() |
| .returning(); |
|
|
| return result; |
| } |
|
|
| export type UpdateInsightParams = { |
| id: string; |
| teamId: string; |
| status?: (typeof insightStatusEnum.enumValues)[number]; |
| title?: string; |
| selectedMetrics?: InsightMetric[]; |
| allMetrics?: Record<string, InsightMetric>; |
| anomalies?: InsightAnomaly[]; |
| expenseAnomalies?: ExpenseAnomaly[]; |
| milestones?: InsightMilestone[]; |
| activity?: InsightActivity; |
| content?: InsightContent; |
| predictions?: InsightPredictions; |
| audioPath?: string; |
| generatedAt?: Date; |
| }; |
|
|
| |
| |
| |
| export async function updateInsight( |
| db: DatabaseOrTransaction, |
| params: UpdateInsightParams, |
| ) { |
| const { id, teamId, generatedAt, ...updateData } = params; |
|
|
| const [result] = await db |
| .update(insights) |
| .set({ |
| ...updateData, |
| ...(generatedAt && { generatedAt }), |
| updatedAt: new Date(), |
| }) |
| .where(and(eq(insights.id, id), eq(insights.teamId, teamId))) |
| .returning(); |
|
|
| return result; |
| } |
|
|
| export type GetInsightsParams = { |
| teamId: string; |
| periodType?: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| cursor?: string | null; |
| pageSize?: number; |
| status?: (typeof insightStatusEnum.enumValues)[number]; |
| }; |
|
|
| |
| |
| |
| export async function getInsights(db: Database, params: GetInsightsParams) { |
| const { teamId, periodType, cursor, pageSize = 10, status } = params; |
|
|
| const offset = cursor ? Number.parseInt(cursor, 10) : 0; |
|
|
| const conditions = [eq(insights.teamId, teamId)]; |
|
|
| if (periodType) { |
| conditions.push(eq(insights.periodType, periodType)); |
| } |
|
|
| if (status) { |
| conditions.push(eq(insights.status, status)); |
| } |
|
|
| const data = await db |
| .select() |
| .from(insights) |
| .where(and(...conditions)) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(pageSize) |
| .offset(offset); |
|
|
| const nextCursor = |
| data && data.length === pageSize |
| ? (offset + pageSize).toString() |
| : undefined; |
|
|
| return { |
| meta: { |
| cursor: nextCursor ?? null, |
| hasPreviousPage: offset > 0, |
| hasNextPage: data && data.length === pageSize, |
| }, |
| data, |
| }; |
| } |
|
|
| export type GetInsightByPeriodParams = { |
| teamId: string; |
| periodType: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| periodYear: number; |
| periodNumber: number; |
| }; |
|
|
| |
| |
| |
| export async function getInsightByPeriod( |
| db: Database, |
| params: GetInsightByPeriodParams, |
| ) { |
| const { teamId, periodType, periodYear, periodNumber } = params; |
|
|
| const [result] = await db |
| .select() |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, periodType), |
| eq(insights.periodYear, periodYear), |
| eq(insights.periodNumber, periodNumber), |
| ), |
| ) |
| .limit(1); |
|
|
| return result ?? null; |
| } |
|
|
| export type GetLatestInsightParams = { |
| teamId: string; |
| periodType?: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| }; |
|
|
| |
| |
| |
| export async function getLatestInsight( |
| db: Database, |
| params: GetLatestInsightParams, |
| ) { |
| const { teamId, periodType } = params; |
|
|
| const conditions = [ |
| eq(insights.teamId, teamId), |
| eq(insights.status, "completed"), |
| ]; |
|
|
| if (periodType) { |
| conditions.push(eq(insights.periodType, periodType)); |
| } |
|
|
| const [result] = await db |
| .select() |
| .from(insights) |
| .where(and(...conditions)) |
| .orderBy(desc(insights.generatedAt)) |
| .limit(1); |
|
|
| return result ?? null; |
| } |
|
|
| |
| |
| |
| export async function getInsightById( |
| db: Database, |
| params: { id: string; teamId: string }, |
| ) { |
| const [result] = await db |
| .select() |
| .from(insights) |
| .where(and(eq(insights.id, params.id), eq(insights.teamId, params.teamId))) |
| .limit(1); |
|
|
| return result ?? null; |
| } |
|
|
| |
| |
| |
| export async function insightExistsForPeriod( |
| db: Database, |
| params: GetInsightByPeriodParams, |
| ): Promise<boolean> { |
| const result = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, params.teamId), |
| eq(insights.periodType, params.periodType), |
| eq(insights.periodYear, params.periodYear), |
| eq(insights.periodNumber, params.periodNumber), |
| ), |
| ); |
|
|
| return (result[0]?.count ?? 0) > 0; |
| } |
|
|
| export type HasEarlierInsightParams = { |
| teamId: string; |
| periodType: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| periodYear: number; |
| periodNumber: number; |
| }; |
|
|
| |
| |
| |
| |
| export async function hasEarlierInsight( |
| db: Database, |
| params: HasEarlierInsightParams, |
| ): Promise<boolean> { |
| const { teamId, periodType, periodYear, periodNumber } = params; |
|
|
| const result = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, periodType), |
| eq(insights.status, "completed"), |
| |
| sql`(${insights.periodYear} < ${periodYear} OR (${insights.periodYear} = ${periodYear} AND ${insights.periodNumber} < ${periodNumber}))`, |
| ), |
| ) |
| .limit(1); |
|
|
| return (result[0]?.count ?? 0) > 0; |
| } |
|
|
| |
| |
| |
|
|
| export type GetInsightActivityDataParams = { |
| teamId: string; |
| from: string; |
| to: string; |
| currency: string; |
| }; |
|
|
| type InvoiceActivityStats = { |
| sent: number; |
| paid: number; |
| largestPayment?: { customer: string; amount: number }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| async function getInvoiceActivityStats( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<InvoiceActivityStats> { |
| const { teamId, from, to } = params; |
|
|
| |
| const [sentResult] = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(invoices) |
| .where( |
| and( |
| eq(invoices.teamId, teamId), |
| isNotNull(invoices.sentAt), |
| gte(invoices.sentAt, from), |
| lte(invoices.sentAt, to), |
| ), |
| ); |
|
|
| |
| const [paidResult] = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(invoices) |
| .where( |
| and( |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "paid"), |
| isNotNull(invoices.paidAt), |
| gte(invoices.paidAt, from), |
| lte(invoices.paidAt, to), |
| ), |
| ); |
|
|
| |
| const [largestPayment] = await db |
| .select({ |
| customerName: invoices.customerName, |
| amount: invoices.amount, |
| }) |
| .from(invoices) |
| .where( |
| and( |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "paid"), |
| isNotNull(invoices.paidAt), |
| gte(invoices.paidAt, from), |
| lte(invoices.paidAt, to), |
| ), |
| ) |
| .orderBy(sql`${invoices.amount} DESC`) |
| .limit(1); |
|
|
| return { |
| sent: sentResult?.count ?? 0, |
| paid: paidResult?.count ?? 0, |
| largestPayment: |
| largestPayment && Number(largestPayment.amount) > 0 |
| ? { |
| customer: largestPayment.customerName ?? "Unknown", |
| amount: Number(largestPayment.amount), |
| } |
| : undefined, |
| }; |
| } |
|
|
| type TrackerActivityStats = { |
| totalHours: number; |
| unbilledHours: number; |
| billableAmount: number; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| async function getTrackerActivityStats( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<TrackerActivityStats> { |
| const { teamId, from, to } = params; |
|
|
| |
| const entries = await db |
| .select({ |
| duration: trackerEntries.duration, |
| billed: trackerEntries.billed, |
| rate: trackerProjects.rate, |
| }) |
| .from(trackerEntries) |
| .leftJoin(trackerProjects, eq(trackerEntries.projectId, trackerProjects.id)) |
| .where( |
| and( |
| eq(trackerEntries.teamId, teamId), |
| gte(trackerEntries.date, from), |
| lte(trackerEntries.date, to), |
| ), |
| ); |
|
|
| let totalSeconds = 0; |
| let unbilledSeconds = 0; |
| let billableAmount = 0; |
|
|
| for (const entry of entries) { |
| const duration = entry.duration ?? 0; |
| totalSeconds += duration; |
|
|
| if (!entry.billed) { |
| unbilledSeconds += duration; |
| |
| const rate = Number(entry.rate ?? 0); |
| billableAmount += (rate * duration) / 3600; |
| } |
| } |
|
|
| return { |
| totalHours: Math.round((totalSeconds / 3600) * 10) / 10, |
| unbilledHours: Math.round((unbilledSeconds / 3600) * 10) / 10, |
| billableAmount: Math.round(billableAmount * 100) / 100, |
| }; |
| } |
|
|
| type CustomerActivityStats = { |
| newCount: number; |
| }; |
|
|
| |
| |
| |
| |
| async function getCustomerActivityStats( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<CustomerActivityStats> { |
| const { teamId, from, to } = params; |
|
|
| const [result] = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(customers) |
| .where( |
| and( |
| eq(customers.teamId, teamId), |
| gte(customers.createdAt, from), |
| lte(customers.createdAt, to), |
| ), |
| ); |
|
|
| return { |
| newCount: result?.count ?? 0, |
| }; |
| } |
|
|
| type InboxActivityStats = { |
| matchedCount: number; |
| }; |
|
|
| |
| |
| |
| |
| async function getInboxActivityStats( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<InboxActivityStats> { |
| const { teamId, from, to } = params; |
|
|
| const [result] = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(inbox) |
| .where( |
| and( |
| eq(inbox.teamId, teamId), |
| eq(inbox.status, "done"), |
| gte(inbox.createdAt, from), |
| lte(inbox.createdAt, to), |
| ), |
| ); |
|
|
| return { |
| matchedCount: result?.count ?? 0, |
| }; |
| } |
|
|
| type TransactionActivityStats = { |
| categorizedCount: number; |
| }; |
|
|
| |
| |
| |
| |
| async function getTransactionActivityStats( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<TransactionActivityStats> { |
| const { teamId, from, to } = params; |
|
|
| |
| |
| const [result] = await db |
| .select({ count: sql<number>`count(*)::int` }) |
| .from(transactions) |
| .where( |
| and( |
| eq(transactions.teamId, teamId), |
| isNotNull(transactions.categorySlug), |
| gte(transactions.date, from), |
| lte(transactions.date, to), |
| ), |
| ); |
|
|
| return { |
| categorizedCount: result?.count ?? 0, |
| }; |
| } |
|
|
| export type InsightActivityData = { |
| invoicesSent: number; |
| invoicesPaid: number; |
| largestPayment?: { customer: string; amount: number }; |
| hoursTracked: number; |
| unbilledHours: number; |
| billableAmount: number; |
| newCustomers: number; |
| receiptsMatched: number; |
| transactionsCategorized: number; |
| }; |
|
|
| |
| |
| |
| |
| export async function getInsightActivityData( |
| db: Database, |
| params: GetInsightActivityDataParams, |
| ): Promise<InsightActivityData> { |
| const [ |
| invoiceStats, |
| trackerStats, |
| customerStats, |
| inboxStats, |
| transactionStats, |
| ] = await Promise.all([ |
| getInvoiceActivityStats(db, params), |
| getTrackerActivityStats(db, params), |
| getCustomerActivityStats(db, params), |
| getInboxActivityStats(db, params), |
| getTransactionActivityStats(db, params), |
| ]); |
|
|
| return { |
| invoicesSent: invoiceStats.sent, |
| invoicesPaid: invoiceStats.paid, |
| largestPayment: invoiceStats.largestPayment, |
| hoursTracked: trackerStats.totalHours, |
| unbilledHours: trackerStats.unbilledHours, |
| billableAmount: trackerStats.billableAmount, |
| newCustomers: customerStats.newCount, |
| receiptsMatched: inboxStats.matchedCount, |
| transactionsCategorized: transactionStats.categorizedCount, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export type InsightUserStatus = typeof insightUserStatus.$inferSelect; |
|
|
| |
| |
| |
| export async function getInsightUserStatus( |
| db: Database, |
| params: { insightId: string; userId: string }, |
| ): Promise<InsightUserStatus | null> { |
| const [result] = await db |
| .select() |
| .from(insightUserStatus) |
| .where( |
| and( |
| eq(insightUserStatus.insightId, params.insightId), |
| eq(insightUserStatus.userId, params.userId), |
| ), |
| ) |
| .limit(1); |
|
|
| return result ?? null; |
| } |
|
|
| |
| |
| |
| |
| export async function markInsightAsRead( |
| db: DatabaseOrTransaction, |
| params: { insightId: string; userId: string }, |
| ): Promise<InsightUserStatus> { |
| const [result] = await db |
| .insert(insightUserStatus) |
| .values({ |
| insightId: params.insightId, |
| userId: params.userId, |
| readAt: new Date(), |
| }) |
| .onConflictDoUpdate({ |
| target: [insightUserStatus.insightId, insightUserStatus.userId], |
| set: { |
| readAt: sql`COALESCE(${insightUserStatus.readAt}, NOW())`, |
| updatedAt: new Date(), |
| }, |
| }) |
| .returning(); |
|
|
| if (!result) { |
| throw new Error("Failed to mark insight as read"); |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| export async function dismissInsight( |
| db: DatabaseOrTransaction, |
| params: { insightId: string; userId: string }, |
| ): Promise<InsightUserStatus> { |
| const [result] = await db |
| .insert(insightUserStatus) |
| .values({ |
| insightId: params.insightId, |
| userId: params.userId, |
| dismissedAt: new Date(), |
| }) |
| .onConflictDoUpdate({ |
| target: [insightUserStatus.insightId, insightUserStatus.userId], |
| set: { |
| dismissedAt: new Date(), |
| updatedAt: new Date(), |
| }, |
| }) |
| .returning(); |
|
|
| if (!result) { |
| throw new Error("Failed to dismiss insight"); |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| export async function undoDismissInsight( |
| db: DatabaseOrTransaction, |
| params: { insightId: string; userId: string }, |
| ): Promise<InsightUserStatus | null> { |
| const [result] = await db |
| .update(insightUserStatus) |
| .set({ |
| dismissedAt: null, |
| updatedAt: new Date(), |
| }) |
| .where( |
| and( |
| eq(insightUserStatus.insightId, params.insightId), |
| eq(insightUserStatus.userId, params.userId), |
| ), |
| ) |
| .returning(); |
|
|
| return result ?? null; |
| } |
|
|
| export type GetInsightsForUserParams = { |
| teamId: string; |
| userId: string; |
| periodType?: (typeof insightPeriodTypeEnum.enumValues)[number]; |
| includeDismissed?: boolean; |
| cursor?: string | null; |
| pageSize?: number; |
| status?: (typeof insightStatusEnum.enumValues)[number]; |
| }; |
|
|
| |
| |
| |
| |
| export async function getInsightsForUser( |
| db: Database, |
| params: GetInsightsForUserParams, |
| ) { |
| const { |
| teamId, |
| userId, |
| periodType, |
| includeDismissed = false, |
| cursor, |
| pageSize = 10, |
| status, |
| } = params; |
|
|
| const offset = cursor ? Number.parseInt(cursor, 10) : 0; |
|
|
| |
| const query = db |
| .select({ |
| insight: insights, |
| userStatus: { |
| readAt: insightUserStatus.readAt, |
| dismissedAt: insightUserStatus.dismissedAt, |
| }, |
| }) |
| .from(insights) |
| .leftJoin( |
| insightUserStatus, |
| and( |
| eq(insightUserStatus.insightId, insights.id), |
| eq(insightUserStatus.userId, userId), |
| ), |
| ); |
|
|
| |
| const conditions = [eq(insights.teamId, teamId)]; |
|
|
| if (periodType) { |
| conditions.push(eq(insights.periodType, periodType)); |
| } |
|
|
| if (status) { |
| conditions.push(eq(insights.status, status)); |
| } |
|
|
| |
| if (!includeDismissed) { |
| conditions.push(isNull(insightUserStatus.dismissedAt)); |
| } |
|
|
| const data = await query |
| .where(and(...conditions)) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(pageSize) |
| .offset(offset); |
|
|
| const nextCursor = |
| data && data.length === pageSize |
| ? (offset + pageSize).toString() |
| : undefined; |
|
|
| return { |
| meta: { |
| cursor: nextCursor ?? null, |
| hasPreviousPage: offset > 0, |
| hasNextPage: data && data.length === pageSize, |
| }, |
| data: data.map((row) => ({ |
| ...row.insight, |
| userStatus: row.userStatus, |
| })), |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export const DATA_QUALITY_THRESHOLDS = { |
| |
| MIN_TRANSACTIONS: 3, |
| |
| |
| MAX_BANK_SYNC_AGE_DAYS: 7, |
| |
| MIN_TOTAL_DATA_POINTS: 3, |
| } as const; |
|
|
| export type DataQualityResult = { |
| |
| hasSufficientData: boolean; |
| |
| skipReason?: string; |
| |
| metrics: { |
| transactionCount: number; |
| invoiceCount: number; |
| hasBankConnection: boolean; |
| lastBankSyncDate: Date | null; |
| bankSyncAgeDays: number | null; |
| }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function checkInsightDataQuality( |
| db: Database, |
| params: { |
| teamId: string; |
| periodStart: string; |
| periodEnd: string; |
| }, |
| ): Promise<DataQualityResult> { |
| const { teamId, periodStart, periodEnd } = params; |
|
|
| |
| const [transactionResult, invoiceResult, bankConnectionResult] = |
| await Promise.all([ |
| |
| db |
| .select({ count: count() }) |
| .from(transactions) |
| .where( |
| and( |
| eq(transactions.teamId, teamId), |
| gte(transactions.date, periodStart), |
| sql`${transactions.date} <= ${periodEnd}`, |
| ), |
| ) |
| .then((r) => r[0]?.count ?? 0), |
|
|
| |
| db |
| .select({ count: count() }) |
| .from(invoices) |
| .where( |
| and( |
| eq(invoices.teamId, teamId), |
| gte(invoices.issueDate, periodStart), |
| sql`${invoices.issueDate} <= ${periodEnd}`, |
| ), |
| ) |
| .then((r) => r[0]?.count ?? 0), |
|
|
| |
| db |
| .select({ |
| lastAccessed: bankConnections.lastAccessed, |
| }) |
| .from(bankConnections) |
| .innerJoin( |
| bankAccounts, |
| eq(bankAccounts.bankConnectionId, bankConnections.id), |
| ) |
| .where( |
| and( |
| eq(bankAccounts.teamId, teamId), |
| eq(bankAccounts.enabled, true), |
| isNotNull(bankConnections.lastAccessed), |
| ), |
| ) |
| .orderBy(sql`${bankConnections.lastAccessed} DESC`) |
| .limit(1) |
| .then((r) => r[0]?.lastAccessed ?? null), |
| ]); |
|
|
| const transactionCount = transactionResult; |
| const invoiceCount = invoiceResult; |
| const lastBankSync = bankConnectionResult |
| ? new Date(bankConnectionResult) |
| : null; |
|
|
| |
| const bankSyncAgeDays = lastBankSync |
| ? Math.floor((Date.now() - lastBankSync.getTime()) / (1000 * 60 * 60 * 24)) |
| : null; |
|
|
| const metrics: DataQualityResult["metrics"] = { |
| transactionCount, |
| invoiceCount, |
| hasBankConnection: lastBankSync !== null, |
| lastBankSyncDate: lastBankSync, |
| bankSyncAgeDays, |
| }; |
|
|
| |
| if (transactionCount < DATA_QUALITY_THRESHOLDS.MIN_TRANSACTIONS) { |
| |
| const totalDataPoints = transactionCount + invoiceCount; |
| if (totalDataPoints < DATA_QUALITY_THRESHOLDS.MIN_TOTAL_DATA_POINTS) { |
| return { |
| hasSufficientData: false, |
| skipReason: `Insufficient data: only ${transactionCount} transactions and ${invoiceCount} invoices in period (minimum ${DATA_QUALITY_THRESHOLDS.MIN_TOTAL_DATA_POINTS} data points required)`, |
| metrics, |
| }; |
| } |
| } |
|
|
| |
| if (lastBankSync && bankSyncAgeDays !== null) { |
| if (bankSyncAgeDays > DATA_QUALITY_THRESHOLDS.MAX_BANK_SYNC_AGE_DAYS) { |
| return { |
| hasSufficientData: false, |
| skipReason: `Stale bank data: last sync was ${bankSyncAgeDays} days ago (maximum ${DATA_QUALITY_THRESHOLDS.MAX_BANK_SYNC_AGE_DAYS} days allowed)`, |
| metrics, |
| }; |
| } |
| } |
|
|
| |
| if (!lastBankSync && invoiceCount === 0 && transactionCount === 0) { |
| return { |
| hasSufficientData: false, |
| skipReason: "No bank connection and no activity in period", |
| metrics, |
| }; |
| } |
|
|
| return { |
| hasSufficientData: true, |
| metrics, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export type OverdueInvoiceDetail = { |
| id: string; |
| invoiceNumber: string; |
| customerName: string; |
| customerEmail?: string; |
| amount: number; |
| currency: string; |
| dueDate: string; |
| daysOverdue: number; |
| }; |
|
|
| |
| |
| |
| |
| export async function getOverdueInvoiceDetails( |
| db: Database, |
| params: { teamId: string; currency?: string }, |
| ): Promise<OverdueInvoiceDetail[]> { |
| const { teamId, currency } = params; |
|
|
| const conditions = [ |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "overdue"), |
| isNotNull(invoices.dueDate), |
| ]; |
|
|
| if (currency) { |
| conditions.push(eq(invoices.currency, currency)); |
| } |
|
|
| const result = await db |
| .select({ |
| id: invoices.id, |
| invoiceNumber: invoices.invoiceNumber, |
| customerName: invoices.customerName, |
| amount: invoices.amount, |
| currency: invoices.currency, |
| dueDate: invoices.dueDate, |
| customerEmail: customers.email, |
| }) |
| .from(invoices) |
| .leftJoin(customers, eq(invoices.customerId, customers.id)) |
| .where(and(...conditions)) |
| .orderBy(sql`${invoices.dueDate} ASC`); |
|
|
| const now = new Date(); |
|
|
| return result.map((inv) => { |
| const dueDate = new Date(inv.dueDate!); |
| const daysOverdue = Math.floor( |
| (now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24), |
| ); |
|
|
| return { |
| id: inv.id, |
| invoiceNumber: inv.invoiceNumber ?? "", |
| customerName: inv.customerName ?? "Unknown", |
| customerEmail: inv.customerEmail ?? undefined, |
| amount: Number(inv.amount ?? 0), |
| currency: inv.currency ?? "USD", |
| dueDate: inv.dueDate!, |
| daysOverdue: Math.max(0, daysOverdue), |
| }; |
| }); |
| } |
|
|
| |
| |
| |
| export type OverdueInvoiceWithBehavior = OverdueInvoiceDetail & { |
| typicalPayDays?: number; |
| isUnusual: boolean; |
| unusualReason?: string; |
| }; |
|
|
| |
| |
| |
| |
| export async function getOverdueInvoicesWithBehavior( |
| db: Database, |
| params: { teamId: string; currency?: string }, |
| ): Promise<OverdueInvoiceWithBehavior[]> { |
| const { teamId, currency } = params; |
|
|
| |
| const overdueInvoices = await getOverdueInvoiceDetails(db, { |
| teamId, |
| currency, |
| }); |
|
|
| if (overdueInvoices.length === 0) { |
| return []; |
| } |
|
|
| |
| const customerConditions = [ |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "paid"), |
| isNotNull(invoices.paidAt), |
| isNotNull(invoices.dueDate), |
| ]; |
|
|
| |
| const paymentBehavior = await db |
| .select({ |
| customerName: invoices.customerName, |
| avgDaysToPay: sql<number>` |
| AVG( |
| EXTRACT(DAY FROM (${invoices.paidAt}::timestamp - ${invoices.dueDate}::timestamp)) |
| )::float |
| `, |
| invoiceCount: sql<number>`COUNT(*)::int`, |
| }) |
| .from(invoices) |
| .where(and(...customerConditions)) |
| .groupBy(invoices.customerName) |
| .having(sql`COUNT(*) >= 2`); |
|
|
| |
| const behaviorMap = new Map<string, { avgDays: number; count: number }>(); |
| for (const row of paymentBehavior) { |
| if (row.customerName) { |
| |
| |
| |
| const normalPayDays = Math.max(0, Math.round(row.avgDaysToPay ?? 0) + 14); |
| behaviorMap.set(row.customerName, { |
| avgDays: normalPayDays, |
| count: row.invoiceCount, |
| }); |
| } |
| } |
|
|
| |
| return overdueInvoices.map((inv) => { |
| const behavior = behaviorMap.get(inv.customerName); |
|
|
| if (!behavior) { |
| |
| return { ...inv, isUnusual: false }; |
| } |
|
|
| |
| const unusualThreshold = Math.max( |
| behavior.avgDays * 1.5, |
| behavior.avgDays + 7, |
| ); |
| const isUnusual = inv.daysOverdue > unusualThreshold; |
|
|
| return { |
| ...inv, |
| typicalPayDays: behavior.avgDays, |
| isUnusual, |
| unusualReason: isUnusual |
| ? `usually pays within ${behavior.avgDays} days` |
| : undefined, |
| }; |
| }); |
| } |
|
|
| export type UnbilledHoursDetail = { |
| projectId: string; |
| projectName: string; |
| customerName?: string; |
| hours: number; |
| rate: number; |
| currency: string; |
| billableAmount: number; |
| }; |
|
|
| |
| |
| |
| |
| export async function getUnbilledHoursDetails( |
| db: Database, |
| params: { teamId: string; currency?: string }, |
| ): Promise<UnbilledHoursDetail[]> { |
| const { teamId, currency } = params; |
|
|
| |
| const result = await db |
| .select({ |
| projectId: trackerProjects.id, |
| projectName: trackerProjects.name, |
| customerName: customers.name, |
| rate: trackerProjects.rate, |
| currency: trackerProjects.currency, |
| totalSeconds: sql<number>`COALESCE(SUM(${trackerEntries.duration}), 0)::int`, |
| }) |
| .from(trackerEntries) |
| .innerJoin( |
| trackerProjects, |
| eq(trackerEntries.projectId, trackerProjects.id), |
| ) |
| .leftJoin(customers, eq(trackerProjects.customerId, customers.id)) |
| .where( |
| and( |
| eq(trackerEntries.teamId, teamId), |
| eq(trackerEntries.billed, false), |
| currency ? eq(trackerProjects.currency, currency) : sql`true`, |
| ), |
| ) |
| .groupBy( |
| trackerProjects.id, |
| trackerProjects.name, |
| trackerProjects.rate, |
| trackerProjects.currency, |
| customers.name, |
| ) |
| .having(sql`SUM(${trackerEntries.duration}) > 0`); |
|
|
| return result |
| .map((row) => { |
| const hours = Math.round((row.totalSeconds / 3600) * 10) / 10; |
| const rate = Number(row.rate ?? 0); |
| const billableAmount = Math.round(hours * rate * 100) / 100; |
|
|
| return { |
| projectId: row.projectId, |
| projectName: row.projectName, |
| customerName: row.customerName ?? undefined, |
| hours, |
| rate, |
| currency: row.currency ?? "USD", |
| billableAmount, |
| }; |
| }) |
| .filter((row) => row.hours > 0) |
| .sort((a, b) => b.billableAmount - a.billableAmount); |
| } |
|
|
| export type DraftInvoiceDetail = { |
| id: string; |
| invoiceNumber?: string; |
| customerName: string; |
| amount: number; |
| currency: string; |
| createdAt: string; |
| }; |
|
|
| |
| |
| |
| |
| export async function getDraftInvoices( |
| db: Database, |
| params: { teamId: string; currency?: string }, |
| ): Promise<DraftInvoiceDetail[]> { |
| const { teamId, currency } = params; |
|
|
| const conditions = [ |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "draft"), |
| ]; |
|
|
| if (currency) { |
| conditions.push(eq(invoices.currency, currency)); |
| } |
|
|
| const result = await db |
| .select({ |
| id: invoices.id, |
| invoiceNumber: invoices.invoiceNumber, |
| customerName: invoices.customerName, |
| amount: invoices.amount, |
| currency: invoices.currency, |
| createdAt: invoices.createdAt, |
| }) |
| .from(invoices) |
| .where(and(...conditions)) |
| .orderBy(sql`${invoices.amount} DESC`); |
|
|
| return result.map((inv) => ({ |
| id: inv.id, |
| invoiceNumber: inv.invoiceNumber ?? undefined, |
| customerName: inv.customerName ?? "Unknown", |
| amount: Number(inv.amount ?? 0), |
| currency: inv.currency ?? "USD", |
| createdAt: inv.createdAt ?? new Date().toISOString(), |
| })); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| export type StreakType = |
| | "revenue_growth" |
| | "revenue_decline" |
| | "profitable" |
| | "invoices_paid_on_time" |
| | null; |
|
|
| export type StreakInfo = { |
| type: StreakType; |
| count: number; |
| description: string | null; |
| }; |
|
|
| export type MomentumType = "accelerating" | "steady" | "decelerating"; |
|
|
| export type RecoveryInfo = { |
| isRecovery: boolean; |
| downWeeksBefore: number; |
| strength?: "strong" | "moderate" | "mild"; |
| description?: string; |
| }; |
|
|
| export type HistoricalContext = { |
| revenueRank: number | null; |
| revenueHighestSince?: string; |
| profitRank: number | null; |
| profitHighestSince?: string; |
| isAllTimeRevenueHigh: boolean; |
| isAllTimeProfitHigh: boolean; |
| isRecentRevenueHigh: boolean; |
| isRecentProfitHigh: boolean; |
| weeksOfHistory: number; |
| yearOverYear?: { |
| lastYearRevenue: number; |
| lastYearProfit: number; |
| revenueChangePercent: number; |
| profitChangePercent: number; |
| hasComparison: boolean; |
| }; |
| quarterPace?: { |
| currentQuarter: number; |
| qtdRevenue: number; |
| projectedRevenue: number; |
| lastYearQuarterRevenue: number; |
| vsLastYearPercent: number; |
| hasComparison: boolean; |
| }; |
| }; |
|
|
| |
| |
| |
| export type InsightHistoryWeek = { |
| periodYear: number; |
| periodNumber: number; |
| periodStart: Date; |
| revenue: number; |
| expenses: number; |
| profit: number; |
| hasOverdue: boolean; |
| invoicesPaid: number; |
| predictions?: InsightPredictions; |
| }; |
|
|
| |
| |
| |
| export type InsightHistoryData = { |
| weeks: InsightHistoryWeek[]; |
| weeksOfHistory: number; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getInsightHistory( |
| db: Database, |
| params: { |
| teamId: string; |
| weeksBack?: number; |
| excludeCurrentPeriod?: { year: number; number: number }; |
| }, |
| ): Promise<InsightHistoryData> { |
| const { teamId, weeksBack = 52, excludeCurrentPeriod } = params; |
|
|
| const conditions = [ |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| ]; |
|
|
| if (excludeCurrentPeriod) { |
| conditions.push( |
| sql`NOT (${insights.periodYear} = ${excludeCurrentPeriod.year} AND ${insights.periodNumber} = ${excludeCurrentPeriod.number})`, |
| ); |
| } |
|
|
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| activity: insights.activity, |
| predictions: insights.predictions, |
| periodStart: insights.periodStart, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where(and(...conditions)) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(weeksBack); |
|
|
| const weeks: InsightHistoryWeek[] = pastInsights |
| .map((insight): InsightHistoryWeek | null => { |
| const metrics = insight.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| const activity = insight.activity as { |
| invoicesOverdue?: number; |
| invoicesPaid?: number; |
| } | null; |
|
|
| if (!metrics) return null; |
|
|
| const week: InsightHistoryWeek = { |
| periodYear: insight.periodYear, |
| periodNumber: insight.periodNumber, |
| periodStart: insight.periodStart, |
| revenue: metrics.revenue?.value ?? 0, |
| expenses: metrics.expenses?.value ?? 0, |
| profit: metrics.netProfit?.value ?? metrics.profit?.value ?? 0, |
| hasOverdue: (activity?.invoicesOverdue ?? 0) > 0, |
| invoicesPaid: activity?.invoicesPaid ?? 0, |
| }; |
|
|
| if (insight.predictions) { |
| week.predictions = insight.predictions; |
| } |
|
|
| return week; |
| }) |
| .filter((w): w is InsightHistoryWeek => w !== null); |
|
|
| return { |
| weeks, |
| weeksOfHistory: weeks.length, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function computeRollingAverages( |
| history: InsightHistoryData, |
| weeksBack = 4, |
| ): RollingAverages { |
| const weeksToUse = history.weeks.slice(0, weeksBack); |
|
|
| if (weeksToUse.length === 0) { |
| return { avgRevenue: 0, avgExpenses: 0, avgProfit: 0, weeksIncluded: 0 }; |
| } |
|
|
| let totalRevenue = 0; |
| let totalExpenses = 0; |
| let totalProfit = 0; |
|
|
| for (const week of weeksToUse) { |
| totalRevenue += week.revenue; |
| totalExpenses += week.expenses; |
| totalProfit += week.profit; |
| } |
|
|
| const count = weeksToUse.length; |
| return { |
| avgRevenue: Math.round((totalRevenue / count) * 100) / 100, |
| avgExpenses: Math.round((totalExpenses / count) * 100) / 100, |
| avgProfit: Math.round((totalProfit / count) * 100) / 100, |
| weeksIncluded: count, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function computeStreakInfo( |
| history: InsightHistoryData, |
| currentWeek: { |
| revenue: number; |
| profit: number; |
| hasOverdue: boolean; |
| invoicesPaid: number; |
| }, |
| ): StreakInfo { |
| if (history.weeks.length === 0) { |
| return { type: null, count: 0, description: null }; |
| } |
|
|
| |
| const weeks = [currentWeek, ...history.weeks.slice(0, 8)]; |
|
|
| |
| let growthStreak = 0; |
| for (let i = 0; i < weeks.length - 1; i++) { |
| if (weeks[i]!.revenue > weeks[i + 1]!.revenue) { |
| growthStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| let declineStreak = 0; |
| for (let i = 0; i < weeks.length - 1; i++) { |
| if (weeks[i]!.revenue < weeks[i + 1]!.revenue) { |
| declineStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| let profitableStreak = 0; |
| for (const week of weeks) { |
| if (week.profit > 0) { |
| profitableStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| |
| |
| let paidOnTimeStreak = 0; |
| for (const week of weeks) { |
| |
| if (week.invoicesPaid > 0 && !week.hasOverdue) { |
| paidOnTimeStreak++; |
| } else if (week.invoicesPaid === 0) { |
| |
| |
| |
| } else { |
| |
| break; |
| } |
| } |
|
|
| |
| if (growthStreak >= 2) { |
| return { |
| type: "revenue_growth", |
| count: growthStreak, |
| description: `${growthStreak} consecutive growth weeks`, |
| }; |
| } |
| if (profitableStreak >= 3) { |
| return { |
| type: "profitable", |
| count: profitableStreak, |
| description: `${profitableStreak} profitable weeks in a row`, |
| }; |
| } |
| if (paidOnTimeStreak >= 3) { |
| return { |
| type: "invoices_paid_on_time", |
| count: paidOnTimeStreak, |
| description: `${paidOnTimeStreak} weeks with all invoices paid on time`, |
| }; |
| } |
| if (declineStreak >= 2) { |
| return { |
| type: "revenue_decline", |
| count: declineStreak, |
| description: `Revenue down ${declineStreak} weeks in a row`, |
| }; |
| } |
|
|
| return { type: null, count: 0, description: null }; |
| } |
|
|
| |
| |
| |
| |
| export function computeHistoricalContext( |
| history: InsightHistoryData, |
| currentWeek: { |
| revenue: number; |
| profit: number; |
| periodYear: number; |
| periodNumber: number; |
| }, |
| ): HistoricalContext { |
| const { revenue: currentRevenue, profit: currentProfit } = currentWeek; |
|
|
| |
| const validWeeks = history.weeks.filter((w) => w.revenue > 0); |
|
|
| if (validWeeks.length < 4) { |
| return { |
| revenueRank: null, |
| profitRank: null, |
| isAllTimeRevenueHigh: false, |
| isAllTimeProfitHigh: false, |
| isRecentRevenueHigh: false, |
| isRecentProfitHigh: false, |
| weeksOfHistory: validWeeks.length, |
| }; |
| } |
|
|
| |
| const revenueRank = |
| validWeeks.filter((w) => w.revenue > currentRevenue).length + 1; |
| const isAllTimeRevenueHigh = revenueRank === 1; |
|
|
| |
| let revenueHighestSince: string | undefined; |
| if (revenueRank <= 3 && revenueRank > 1) { |
| const higherWeek = validWeeks.find((w) => w.revenue > currentRevenue); |
| if (higherWeek) { |
| revenueHighestSince = format(higherWeek.periodStart, "MMMM yyyy"); |
| } |
| } |
|
|
| |
| const profitRank = |
| validWeeks.filter((w) => w.profit > currentProfit).length + 1; |
| const isAllTimeProfitHigh = profitRank === 1 && currentProfit > 0; |
|
|
| |
| let profitHighestSince: string | undefined; |
| if (profitRank <= 3 && profitRank > 1 && currentProfit > 0) { |
| const higherWeek = validWeeks.find((w) => w.profit > currentProfit); |
| if (higherWeek) { |
| profitHighestSince = format(higherWeek.periodStart, "MMMM yyyy"); |
| } |
| } |
|
|
| |
| const isRecentRevenueHigh = revenueRank <= 3 && validWeeks.length >= 8; |
| const isRecentProfitHigh = |
| profitRank <= 3 && validWeeks.length >= 8 && currentProfit > 0; |
|
|
| |
| let yearOverYear: HistoricalContext["yearOverYear"]; |
| const lastYearInsight = history.weeks.find( |
| (w) => |
| w.periodYear === currentWeek.periodYear - 1 && |
| w.periodNumber === currentWeek.periodNumber, |
| ); |
|
|
| if (lastYearInsight && lastYearInsight.revenue > 0) { |
| const revenueChangePercent = Math.round( |
| ((currentRevenue - lastYearInsight.revenue) / lastYearInsight.revenue) * |
| 100, |
| ); |
| const profitChangePercent = |
| lastYearInsight.profit !== 0 |
| ? Math.round( |
| ((currentProfit - lastYearInsight.profit) / |
| Math.abs(lastYearInsight.profit)) * |
| 100, |
| ) |
| : 0; |
|
|
| yearOverYear = { |
| lastYearRevenue: lastYearInsight.revenue, |
| lastYearProfit: lastYearInsight.profit, |
| revenueChangePercent, |
| profitChangePercent, |
| hasComparison: true, |
| }; |
| } |
|
|
| |
| let quarterPace: HistoricalContext["quarterPace"]; |
| const now = new Date(); |
| const currentQuarter = getQuarter(now); |
| const quarterStart = startOfQuarter(now); |
| const quarterEnd = endOfQuarter(now); |
| const daysElapsed = differenceInDays(now, quarterStart) + 1; |
| const totalQuarterDays = differenceInDays(quarterEnd, quarterStart) + 1; |
|
|
| |
| const quarterWeeks = history.weeks.filter((w) => { |
| return ( |
| w.periodStart >= quarterStart && |
| w.periodStart <= now && |
| w.periodYear === currentWeek.periodYear |
| ); |
| }); |
|
|
| |
| const qtdRevenue = |
| quarterWeeks.reduce((sum, w) => sum + w.revenue, 0) + currentRevenue; |
|
|
| if (qtdRevenue > 0 && daysElapsed > 7) { |
| |
| const projectedRevenue = Math.round( |
| (qtdRevenue / daysElapsed) * totalQuarterDays, |
| ); |
|
|
| |
| const lastYearQuarterWeeks = history.weeks.filter((w) => { |
| const weekQuarter = getQuarter(w.periodStart); |
| return ( |
| weekQuarter === currentQuarter && |
| w.periodYear === currentWeek.periodYear - 1 |
| ); |
| }); |
|
|
| const lastYearQuarterRevenue = lastYearQuarterWeeks.reduce( |
| (sum, w) => sum + w.revenue, |
| 0, |
| ); |
|
|
| const vsLastYearPercent = |
| lastYearQuarterRevenue > 0 |
| ? Math.round( |
| ((projectedRevenue - lastYearQuarterRevenue) / |
| lastYearQuarterRevenue) * |
| 100, |
| ) |
| : 0; |
|
|
| quarterPace = { |
| currentQuarter, |
| qtdRevenue, |
| projectedRevenue, |
| lastYearQuarterRevenue, |
| vsLastYearPercent, |
| hasComparison: lastYearQuarterRevenue > 0, |
| }; |
| } |
|
|
| return { |
| revenueRank, |
| revenueHighestSince, |
| profitRank, |
| profitHighestSince, |
| isAllTimeRevenueHigh, |
| isAllTimeProfitHigh, |
| isRecentRevenueHigh, |
| isRecentProfitHigh, |
| weeksOfHistory: validWeeks.length, |
| yearOverYear, |
| quarterPace, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function computeMomentum( |
| history: InsightHistoryData, |
| currentRevenue: number, |
| ): { |
| momentum: MomentumType; |
| currentGrowthRate: number; |
| previousGrowthRate: number; |
| } | null { |
| if (history.weeks.length < 2) { |
| return null; |
| } |
|
|
| const prevRevenue = history.weeks[0]!.revenue; |
| const weekBeforeRevenue = history.weeks[1]!.revenue; |
|
|
| if (prevRevenue === 0 || weekBeforeRevenue === 0) { |
| return null; |
| } |
|
|
| const currentGrowthRate = |
| ((currentRevenue - prevRevenue) / prevRevenue) * 100; |
| const previousGrowthRate = |
| ((prevRevenue - weekBeforeRevenue) / weekBeforeRevenue) * 100; |
|
|
| return { |
| momentum: detectMomentum(currentGrowthRate, previousGrowthRate), |
| currentGrowthRate: Math.round(currentGrowthRate * 10) / 10, |
| previousGrowthRate: Math.round(previousGrowthRate * 10) / 10, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function computeRecovery( |
| history: InsightHistoryData, |
| currentRevenue: number, |
| ): RecoveryInfo { |
| if (history.weeks.length < 2) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| const revenues = history.weeks |
| .filter((w) => w.revenue > 0) |
| .map((w) => w.revenue); |
| if (revenues.length < 2) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| const previousRevenue = revenues[0]!; |
|
|
| |
| if (currentRevenue <= previousRevenue) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| |
| let downWeeksBefore = 0; |
| for (let i = 0; i < revenues.length - 1; i++) { |
| if (revenues[i]! < revenues[i + 1]!) { |
| downWeeksBefore++; |
| } else { |
| break; |
| } |
| } |
|
|
| if (downWeeksBefore === 0) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| |
| const recoveryPercent = |
| ((currentRevenue - previousRevenue) / previousRevenue) * 100; |
| let strength: "strong" | "moderate" | "mild"; |
| if (recoveryPercent >= 20) { |
| strength = "strong"; |
| } else if (recoveryPercent >= 10) { |
| strength = "moderate"; |
| } else { |
| strength = "mild"; |
| } |
|
|
| const description = |
| downWeeksBefore >= 3 |
| ? `Bounced back after ${downWeeksBefore} down weeks` |
| : downWeeksBefore === 2 |
| ? "Bounced back after 2 down weeks" |
| : "Bounced back from last week's dip"; |
|
|
| return { |
| isRecovery: true, |
| downWeeksBefore, |
| strength, |
| description, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export function getPredictionsFromHistory(history: InsightHistoryData): { |
| predictions: InsightPredictions | null; |
| periodStart: Date | null; |
| } | null { |
| if (history.weeks.length === 0) { |
| return null; |
| } |
|
|
| const previousWeek = history.weeks[0]; |
| return { |
| predictions: previousWeek?.predictions ?? null, |
| periodStart: previousWeek?.periodStart ?? null, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export type RollingAverages = { |
| avgRevenue: number; |
| avgExpenses: number; |
| avgProfit: number; |
| weeksIncluded: number; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getRollingAverages( |
| db: Database, |
| params: { |
| teamId: string; |
| weeksBack?: number; |
| currentPeriodYear?: number; |
| currentPeriodNumber?: number; |
| }, |
| ): Promise<RollingAverages> { |
| const { |
| teamId, |
| weeksBack = 4, |
| currentPeriodYear, |
| currentPeriodNumber, |
| } = params; |
|
|
| |
| const conditions = [ |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| ]; |
|
|
| |
| if (currentPeriodYear && currentPeriodNumber) { |
| conditions.push( |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ); |
| } |
|
|
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where(and(...conditions)) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(weeksBack); |
|
|
| if (pastInsights.length === 0) { |
| return { |
| avgRevenue: 0, |
| avgExpenses: 0, |
| avgProfit: 0, |
| weeksIncluded: 0, |
| }; |
| } |
|
|
| |
| let totalRevenue = 0; |
| let totalExpenses = 0; |
| let totalProfit = 0; |
| let validWeeks = 0; |
|
|
| for (const insight of pastInsights) { |
| const metrics = insight.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| if (!metrics) continue; |
|
|
| const revenue = metrics.revenue?.value ?? 0; |
| const expenses = metrics.expenses?.value ?? 0; |
| const profit = metrics.netProfit?.value ?? metrics.profit?.value ?? 0; |
|
|
| totalRevenue += revenue; |
| totalExpenses += expenses; |
| totalProfit += profit; |
| validWeeks++; |
| } |
|
|
| if (validWeeks === 0) { |
| return { |
| avgRevenue: 0, |
| avgExpenses: 0, |
| avgProfit: 0, |
| weeksIncluded: 0, |
| }; |
| } |
|
|
| return { |
| avgRevenue: Math.round((totalRevenue / validWeeks) * 100) / 100, |
| avgExpenses: Math.round((totalExpenses / validWeeks) * 100) / 100, |
| avgProfit: Math.round((totalProfit / validWeeks) * 100) / 100, |
| weeksIncluded: validWeeks, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getStreakInfo( |
| db: Database, |
| params: { |
| teamId: string; |
| currentPeriodYear: number; |
| currentPeriodNumber: number; |
| currentRevenue: number; |
| currentProfit: number; |
| hasOverdueInvoices: boolean; |
| }, |
| ): Promise<StreakInfo> { |
| const { |
| teamId, |
| currentPeriodYear, |
| currentPeriodNumber, |
| currentRevenue, |
| currentProfit, |
| hasOverdueInvoices, |
| } = params; |
|
|
| |
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| activity: insights.activity, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ), |
| ) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(8); |
|
|
| if (pastInsights.length === 0) { |
| return { type: null, count: 0, description: null }; |
| } |
|
|
| |
| type WeekData = { |
| revenue: number; |
| profit: number; |
| hasOverdue: boolean; |
| }; |
|
|
| const weeks: WeekData[] = [ |
| { |
| revenue: currentRevenue, |
| profit: currentProfit, |
| hasOverdue: hasOverdueInvoices, |
| }, |
| ]; |
|
|
| for (const insight of pastInsights) { |
| const metrics = insight.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| const activity = insight.activity as { invoicesOverdue?: number } | null; |
|
|
| if (!metrics) continue; |
|
|
| weeks.push({ |
| revenue: metrics.revenue?.value ?? 0, |
| profit: metrics.netProfit?.value ?? metrics.profit?.value ?? 0, |
| hasOverdue: (activity?.invoicesOverdue ?? 0) > 0, |
| }); |
| } |
|
|
| |
| let growthStreak = 0; |
| for (let i = 0; i < weeks.length - 1; i++) { |
| if (weeks[i]!.revenue > weeks[i + 1]!.revenue) { |
| growthStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| let declineStreak = 0; |
| for (let i = 0; i < weeks.length - 1; i++) { |
| if (weeks[i]!.revenue < weeks[i + 1]!.revenue) { |
| declineStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| let profitableStreak = 0; |
| for (const week of weeks) { |
| if (week.profit > 0) { |
| profitableStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| let paidOnTimeStreak = 0; |
| for (const week of weeks) { |
| if (!week.hasOverdue) { |
| paidOnTimeStreak++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| if (growthStreak >= 2) { |
| return { |
| type: "revenue_growth", |
| count: growthStreak, |
| description: `${growthStreak} consecutive growth weeks`, |
| }; |
| } |
|
|
| if (profitableStreak >= 3) { |
| return { |
| type: "profitable", |
| count: profitableStreak, |
| description: `${profitableStreak} profitable weeks in a row`, |
| }; |
| } |
|
|
| if (paidOnTimeStreak >= 3) { |
| return { |
| type: "invoices_paid_on_time", |
| count: paidOnTimeStreak, |
| description: `${paidOnTimeStreak} weeks with all invoices paid on time`, |
| }; |
| } |
|
|
| if (declineStreak >= 2) { |
| return { |
| type: "revenue_decline", |
| count: declineStreak, |
| description: `Revenue down ${declineStreak} weeks in a row`, |
| }; |
| } |
|
|
| return { type: null, count: 0, description: null }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export async function getHistoricalContext( |
| db: Database, |
| params: { |
| teamId: string; |
| currentRevenue: number; |
| currentProfit: number; |
| currentPeriodYear: number; |
| currentPeriodNumber: number; |
| }, |
| ): Promise<HistoricalContext> { |
| const { |
| teamId, |
| currentRevenue, |
| currentProfit, |
| currentPeriodYear, |
| currentPeriodNumber, |
| } = params; |
|
|
| |
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| periodStart: insights.periodStart, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ), |
| ) |
| .orderBy(desc(insights.periodStart)) |
| .limit(52); |
|
|
| const weeksOfHistory = pastInsights.length; |
|
|
| |
| if (weeksOfHistory < 4) { |
| return { |
| revenueRank: null, |
| profitRank: null, |
| isAllTimeRevenueHigh: false, |
| isAllTimeProfitHigh: false, |
| isRecentRevenueHigh: false, |
| isRecentProfitHigh: false, |
| weeksOfHistory, |
| }; |
| } |
|
|
| |
| const historicalWeeks = pastInsights |
| .map((insight) => { |
| const metrics = insight.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| if (!metrics) return null; |
|
|
| return { |
| revenue: metrics.revenue?.value ?? 0, |
| profit: metrics.netProfit?.value ?? metrics.profit?.value ?? 0, |
| periodStart: insight.periodStart, |
| }; |
| }) |
| .filter( |
| (w): w is { revenue: number; profit: number; periodStart: Date } => |
| w !== null && w.revenue > 0, |
| ); |
|
|
| if (historicalWeeks.length < 4) { |
| return { |
| revenueRank: null, |
| profitRank: null, |
| isAllTimeRevenueHigh: false, |
| isAllTimeProfitHigh: false, |
| isRecentRevenueHigh: false, |
| isRecentProfitHigh: false, |
| weeksOfHistory: historicalWeeks.length, |
| }; |
| } |
|
|
| |
| const revenueRank = |
| historicalWeeks.filter((w) => w.revenue > currentRevenue).length + 1; |
| const isAllTimeRevenueHigh = revenueRank === 1; |
|
|
| |
| let revenueHighestSince: string | undefined; |
| if (revenueRank <= 3 && revenueRank > 1) { |
| const higherWeek = historicalWeeks.find((w) => w.revenue > currentRevenue); |
| if (higherWeek) { |
| revenueHighestSince = format(higherWeek.periodStart, "MMMM yyyy"); |
| } |
| } |
|
|
| |
| const profitRank = |
| historicalWeeks.filter((w) => w.profit > currentProfit).length + 1; |
| const isAllTimeProfitHigh = profitRank === 1 && currentProfit > 0; |
|
|
| |
| let profitHighestSince: string | undefined; |
| if (profitRank <= 3 && profitRank > 1 && currentProfit > 0) { |
| const higherWeek = historicalWeeks.find((w) => w.profit > currentProfit); |
| if (higherWeek) { |
| profitHighestSince = format(higherWeek.periodStart, "MMMM yyyy"); |
| } |
| } |
|
|
| |
| const isRecentRevenueHigh = revenueRank <= 3 && historicalWeeks.length >= 8; |
| const isRecentProfitHigh = |
| profitRank <= 3 && historicalWeeks.length >= 8 && currentProfit > 0; |
|
|
| |
| let yearOverYear: HistoricalContext["yearOverYear"]; |
| const lastYearWeekNumber = currentPeriodNumber; |
| const lastYearYear = currentPeriodYear - 1; |
|
|
| |
| const lastYearInsight = pastInsights.find( |
| (insight) => |
| insight.periodYear === lastYearYear && |
| insight.periodNumber === lastYearWeekNumber, |
| ); |
|
|
| if (lastYearInsight) { |
| const lastYearMetrics = lastYearInsight.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| if (lastYearMetrics) { |
| const lastYearRevenue = lastYearMetrics.revenue?.value ?? 0; |
| const lastYearProfit = |
| lastYearMetrics.netProfit?.value ?? lastYearMetrics.profit?.value ?? 0; |
|
|
| |
| const revenueChangePercent = |
| lastYearRevenue > 0 |
| ? Math.round( |
| ((currentRevenue - lastYearRevenue) / lastYearRevenue) * 100, |
| ) |
| : 0; |
| const profitChangePercent = |
| lastYearProfit !== 0 |
| ? Math.round( |
| ((currentProfit - lastYearProfit) / Math.abs(lastYearProfit)) * |
| 100, |
| ) |
| : 0; |
|
|
| yearOverYear = { |
| lastYearRevenue, |
| lastYearProfit, |
| revenueChangePercent, |
| profitChangePercent, |
| hasComparison: lastYearRevenue > 0, |
| }; |
| } |
| } |
|
|
| return { |
| revenueRank, |
| revenueHighestSince, |
| profitRank, |
| profitHighestSince, |
| isAllTimeRevenueHigh, |
| isAllTimeProfitHigh, |
| isRecentRevenueHigh, |
| isRecentProfitHigh, |
| weeksOfHistory: historicalWeeks.length, |
| yearOverYear, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export type UpcomingInvoicesResult = { |
| totalDue: number; |
| count: number; |
| currency: string; |
| }; |
|
|
| |
| |
| |
| |
| export async function getUpcomingInvoicesForInsight( |
| db: Database, |
| params: { |
| teamId: string; |
| fromDate: Date; |
| toDate: Date; |
| currency?: string; |
| }, |
| ): Promise<UpcomingInvoicesResult> { |
| const { teamId, fromDate, toDate, currency } = params; |
|
|
| const conditions = [ |
| eq(invoices.teamId, teamId), |
| |
| sql`${invoices.status} IN ('unpaid', 'overdue')`, |
| isNotNull(invoices.dueDate), |
| gte(invoices.dueDate, fromDate.toISOString()), |
| lte(invoices.dueDate, toDate.toISOString()), |
| ]; |
|
|
| if (currency) { |
| conditions.push(eq(invoices.currency, currency)); |
| } |
|
|
| const result = await db |
| .select({ |
| totalAmount: sql<number>`COALESCE(SUM(${invoices.amount}), 0)::numeric`, |
| invoiceCount: sql<number>`COUNT(*)::int`, |
| currency: invoices.currency, |
| }) |
| .from(invoices) |
| .where(and(...conditions)) |
| .groupBy(invoices.currency); |
|
|
| |
| const row = result[0]; |
| return { |
| totalDue: Number(row?.totalAmount ?? 0), |
| count: row?.invoiceCount ?? 0, |
| currency: row?.currency ?? currency ?? "USD", |
| }; |
| } |
|
|
| export type OverdueInvoicesSummary = { |
| count: number; |
| totalAmount: number; |
| oldestDays: number; |
| currency: string; |
| }; |
|
|
| |
| |
| |
| |
| export async function getOverdueInvoicesSummary( |
| db: Database, |
| params: { |
| teamId: string; |
| asOfDate: Date; |
| currency?: string; |
| }, |
| ): Promise<OverdueInvoicesSummary> { |
| const { teamId, asOfDate, currency } = params; |
|
|
| const conditions = [ |
| eq(invoices.teamId, teamId), |
| eq(invoices.status, "overdue"), |
| isNotNull(invoices.dueDate), |
| ]; |
|
|
| if (currency) { |
| conditions.push(eq(invoices.currency, currency)); |
| } |
|
|
| |
| const [summaryResult] = await db |
| .select({ |
| totalAmount: sql<number>`COALESCE(SUM(${invoices.amount}), 0)::numeric`, |
| invoiceCount: sql<number>`COUNT(*)::int`, |
| oldestDueDate: sql<string>`MIN(${invoices.dueDate})`, |
| currency: invoices.currency, |
| }) |
| .from(invoices) |
| .where(and(...conditions)) |
| .groupBy(invoices.currency); |
|
|
| if (!summaryResult || summaryResult.invoiceCount === 0) { |
| return { |
| count: 0, |
| totalAmount: 0, |
| oldestDays: 0, |
| currency: currency ?? "USD", |
| }; |
| } |
|
|
| |
| const oldestDueDate = new Date(summaryResult.oldestDueDate); |
| const oldestDays = Math.floor( |
| (asOfDate.getTime() - oldestDueDate.getTime()) / (1000 * 60 * 60 * 24), |
| ); |
|
|
| return { |
| count: summaryResult.invoiceCount, |
| totalAmount: Number(summaryResult.totalAmount), |
| oldestDays: Math.max(0, oldestDays), |
| currency: summaryResult.currency ?? currency ?? "USD", |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function detectMomentum( |
| currentGrowthRate: number, |
| previousGrowthRate: number, |
| ): MomentumType { |
| const difference = currentGrowthRate - previousGrowthRate; |
|
|
| |
| if (difference > 5) { |
| return "accelerating"; |
| } |
| if (difference < -5) { |
| return "decelerating"; |
| } |
| return "steady"; |
| } |
|
|
| |
| |
| |
| |
| export async function getMomentumFromHistory( |
| db: Database, |
| params: { |
| teamId: string; |
| currentRevenue: number; |
| currentPeriodYear: number; |
| currentPeriodNumber: number; |
| }, |
| ): Promise<{ |
| momentum: MomentumType; |
| currentGrowthRate: number; |
| previousGrowthRate: number; |
| } | null> { |
| const { teamId, currentRevenue, currentPeriodYear, currentPeriodNumber } = |
| params; |
|
|
| |
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ), |
| ) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(2); |
|
|
| if (pastInsights.length < 2) { |
| return null; |
| } |
|
|
| |
| const previousWeek = pastInsights[0]!.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
| const weekBefore = pastInsights[1]!.allMetrics as Record< |
| string, |
| { value: number } |
| > | null; |
|
|
| const prevRevenue = previousWeek?.revenue?.value ?? 0; |
| const weekBeforeRevenue = weekBefore?.revenue?.value ?? 0; |
|
|
| if (prevRevenue === 0 || weekBeforeRevenue === 0) { |
| return null; |
| } |
|
|
| |
| const currentGrowthRate = |
| ((currentRevenue - prevRevenue) / prevRevenue) * 100; |
| const previousGrowthRate = |
| ((prevRevenue - weekBeforeRevenue) / weekBeforeRevenue) * 100; |
|
|
| return { |
| momentum: detectMomentum(currentGrowthRate, previousGrowthRate), |
| currentGrowthRate: Math.round(currentGrowthRate * 10) / 10, |
| previousGrowthRate: Math.round(previousGrowthRate * 10) / 10, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export async function detectRecovery( |
| db: Database, |
| params: { |
| teamId: string; |
| currentRevenue: number; |
| currentPeriodYear: number; |
| currentPeriodNumber: number; |
| }, |
| ): Promise<RecoveryInfo> { |
| const { teamId, currentRevenue, currentPeriodYear, currentPeriodNumber } = |
| params; |
|
|
| |
| const pastInsights = await db |
| .select({ |
| allMetrics: insights.allMetrics, |
| periodYear: insights.periodYear, |
| periodNumber: insights.periodNumber, |
| }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| isNotNull(insights.allMetrics), |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ), |
| ) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(8); |
|
|
| if (pastInsights.length < 2) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| |
| const revenues = pastInsights |
| .map((i) => { |
| const metrics = i.allMetrics as Record<string, { value: number }> | null; |
| return metrics?.revenue?.value ?? 0; |
| }) |
| .filter((r) => r > 0); |
|
|
| if (revenues.length < 2) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| const previousRevenue = revenues[0]!; |
|
|
| |
| if (currentRevenue <= previousRevenue) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| |
| let downWeeksBefore = 0; |
| for (let i = 0; i < revenues.length - 1; i++) { |
| if (revenues[i]! < revenues[i + 1]!) { |
| downWeeksBefore++; |
| } else { |
| break; |
| } |
| } |
|
|
| |
| if (downWeeksBefore === 0) { |
| return { isRecovery: false, downWeeksBefore: 0 }; |
| } |
|
|
| |
| const recoveryPercent = |
| ((currentRevenue - previousRevenue) / previousRevenue) * 100; |
| let strength: "strong" | "moderate" | "mild"; |
| if (recoveryPercent >= 20) { |
| strength = "strong"; |
| } else if (recoveryPercent >= 10) { |
| strength = "moderate"; |
| } else { |
| strength = "mild"; |
| } |
|
|
| |
| let description: string; |
| if (downWeeksBefore >= 3) { |
| description = `Bounced back after ${downWeeksBefore} down weeks`; |
| } else if (downWeeksBefore === 2) { |
| description = "Bounced back after 2 down weeks"; |
| } else { |
| description = "Bounced back from last week's dip"; |
| } |
|
|
| return { |
| isRecovery: true, |
| downWeeksBefore, |
| strength, |
| description, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export async function getPreviousInsightPredictions( |
| db: Database, |
| params: { |
| teamId: string; |
| currentPeriodYear: number; |
| currentPeriodNumber: number; |
| }, |
| ): Promise<{ |
| predictions: InsightPredictions | null; |
| periodStart: Date | null; |
| } | null> { |
| const { teamId, currentPeriodYear, currentPeriodNumber } = params; |
|
|
| |
| const [previousInsight] = await db |
| .select({ |
| predictions: insights.predictions, |
| periodStart: insights.periodStart, |
| }) |
| .from(insights) |
| .where( |
| and( |
| eq(insights.teamId, teamId), |
| eq(insights.periodType, "weekly"), |
| eq(insights.status, "completed"), |
| sql`NOT (${insights.periodYear} = ${currentPeriodYear} AND ${insights.periodNumber} = ${currentPeriodNumber})`, |
| ), |
| ) |
| .orderBy(desc(insights.periodYear), desc(insights.periodNumber)) |
| .limit(1); |
|
|
| if (!previousInsight) { |
| return null; |
| } |
|
|
| return { |
| predictions: previousInsight.predictions, |
| periodStart: previousInsight.periodStart, |
| }; |
| } |
|
|