Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
@@ -1,11 +1,4 @@
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
export {
|
||||
EXPENSE_CATEGORIES,
|
||||
type ExpenseCategory,
|
||||
} from "@beenvoice/domain/expense-categories";
|
||||
|
||||
@@ -1,78 +1,36 @@
|
||||
import {
|
||||
getDaysPastDue as getSharedDaysPastDue,
|
||||
getEffectiveInvoiceStatus as getSharedEffectiveInvoiceStatus,
|
||||
getValidStatusTransitions as getSharedValidStatusTransitions,
|
||||
isInvoiceOverdue as isSharedInvoiceOverdue,
|
||||
isValidStatusTransition as isSharedValidStatusTransition,
|
||||
} from "@beenvoice/domain/invoice-status";
|
||||
import type {
|
||||
StoredInvoiceStatus,
|
||||
EffectiveInvoiceStatus,
|
||||
StoredInvoiceStatus,
|
||||
} from "~/types/invoice";
|
||||
|
||||
// Types are now imported from ~/types/invoice
|
||||
|
||||
/**
|
||||
* Calculate the effective status of an invoice including overdue computation
|
||||
*/
|
||||
export function getEffectiveInvoiceStatus(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): EffectiveInvoiceStatus {
|
||||
// If already paid, status is always paid regardless of due date
|
||||
if (storedStatus === "paid") {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
// If draft, status is always draft
|
||||
if (storedStatus === "draft") {
|
||||
return "draft";
|
||||
}
|
||||
|
||||
// For sent invoices, check if overdue
|
||||
if (storedStatus === "sent") {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
// Set both dates to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
return due < today ? "overdue" : "sent";
|
||||
}
|
||||
|
||||
return storedStatus;
|
||||
return getSharedEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue
|
||||
*/
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): boolean {
|
||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
||||
return isSharedInvoiceOverdue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days past due (returns 0 if not overdue)
|
||||
*/
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): number {
|
||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
return getSharedDaysPastDue(storedStatus, dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status configuration for UI display
|
||||
*/
|
||||
export const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
@@ -96,42 +54,22 @@ export const statusConfig = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration for display
|
||||
*/
|
||||
export function getStatusConfig(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return statusConfig[effectiveStatus];
|
||||
return statusConfig[getEffectiveInvoiceStatus(storedStatus, dueDate)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status transitions from current stored status
|
||||
*/
|
||||
export function getValidStatusTransitions(
|
||||
currentStatus: StoredInvoiceStatus,
|
||||
): StoredInvoiceStatus[] {
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
return ["sent", "paid"]; // Can send or mark paid directly
|
||||
case "sent":
|
||||
return ["paid", "draft"]; // Can mark paid or revert to draft
|
||||
case "paid":
|
||||
return ["sent"]; // Can revert to sent if needed (rare cases)
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return getSharedValidStatusTransitions(currentStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(
|
||||
from: StoredInvoiceStatus,
|
||||
to: StoredInvoiceStatus,
|
||||
): boolean {
|
||||
const validTransitions = getValidStatusTransitions(from);
|
||||
return validTransitions.includes(to);
|
||||
return isSharedValidStatusTransition(from, to);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,4 @@
|
||||
export type ReceiptParseResult = {
|
||||
amount: number | null;
|
||||
date: Date | null;
|
||||
vendor: string | null;
|
||||
rawLines: string[];
|
||||
};
|
||||
|
||||
const AMOUNT_PATTERNS = [
|
||||
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
|
||||
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
|
||||
];
|
||||
|
||||
const DATE_PATTERNS = [
|
||||
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
|
||||
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
|
||||
];
|
||||
|
||||
function parseAmount(text: string): number | null {
|
||||
for (const pattern of AMOUNT_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const value = Number(match[1].replace(/,/g, ""));
|
||||
if (Number.isFinite(value) && value > 0) return value;
|
||||
}
|
||||
|
||||
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
|
||||
return amounts.length > 0 ? Math.max(...amounts) : null;
|
||||
}
|
||||
|
||||
function parseDate(text: string): Date | null {
|
||||
for (const pattern of DATE_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const parsed = new Date(match[1]);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseVendor(lines: string[]): string | null {
|
||||
const candidate = lines.find((line) => line.trim().length >= 3);
|
||||
return candidate?.trim().slice(0, 120) ?? null;
|
||||
}
|
||||
|
||||
/** Heuristic receipt field extraction from OCR or pasted text. */
|
||||
export function parseReceiptText(text: string): ReceiptParseResult {
|
||||
const normalized = text.replace(/\r/g, "\n").trim();
|
||||
const rawLines = normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
amount: parseAmount(normalized),
|
||||
date: parseDate(normalized),
|
||||
vendor: parseVendor(rawLines),
|
||||
rawLines,
|
||||
};
|
||||
}
|
||||
export {
|
||||
parseReceiptText,
|
||||
type ReceiptParseResult,
|
||||
} from "@beenvoice/domain/receipt-parse";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
import { LEGACY_DEFAULT_CLOCK_DESCRIPTION } from "@beenvoice/domain/time-clock";
|
||||
import type { ClockOutOutcome } from "@beenvoice/domain/time-clock";
|
||||
|
||||
export {
|
||||
formatElapsedSeconds,
|
||||
LEGACY_DEFAULT_CLOCK_DESCRIPTION,
|
||||
type ClockOutOutcome,
|
||||
} from "@beenvoice/domain/time-clock";
|
||||
|
||||
export function normalizeOptionalId(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
@@ -44,24 +50,11 @@ export function resolveBillingDescription(description?: string | null): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export type ClockOutOutcome =
|
||||
| "linked_to_invoice"
|
||||
| "saved_no_invoice"
|
||||
| "saved_no_client"
|
||||
| "zero_hours";
|
||||
|
||||
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||
}
|
||||
|
||||
export function formatElapsedSeconds(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
export function describeClockOutOutcome(input: {
|
||||
outcome: ClockOutOutcome;
|
||||
hours: number;
|
||||
|
||||
Reference in New Issue
Block a user