Convert Beenvoice to a Turborepo monorepo

This commit is contained in:
2026-08-16 22:13:42 -04:00
parent d057ba208d
commit 6c74436092
54 changed files with 4140 additions and 4147 deletions
+13
View File
@@ -0,0 +1,13 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
+4
View File
@@ -0,0 +1,4 @@
export * from "./expense-categories";
export * from "./invoice-status";
export * from "./receipt-parse";
export * from "./time-clock";
+54
View File
@@ -0,0 +1,54 @@
export type StoredInvoiceStatus = "draft" | "sent" | "paid";
export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "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);
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
}
export function getValidStatusTransitions(
currentStatus: StoredInvoiceStatus,
): StoredInvoiceStatus[] {
switch (currentStatus) {
case "draft":
return ["sent", "paid"];
case "sent":
return ["paid", "draft"];
case "paid":
return ["sent"];
}
}
export function isValidStatusTransition(
from: StoredInvoiceStatus,
to: StoredInvoiceStatus,
): boolean {
return getValidStatusTransitions(from).includes(to);
}
+93
View File
@@ -0,0 +1,93 @@
export type ReceiptParseResult = {
amount: number | null;
date: Date | null;
subtotal: number | null;
tax: number | null;
vendor: string | null;
items: ReceiptLineItem[];
rawLines: string[];
};
export type ReceiptLineItem = {
id: string;
name: string;
amount: number;
rawLine: string;
};
const AMOUNT_PATTERNS = [
/^\s*(?:grand total|total|amount due|balance due)[:\s]*\$?\s*([\d,]+\.\d{2})/im,
/\$\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})/,
];
const SUBTOTAL_PATTERNS = [/(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
const TAX_PATTERNS = [/(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i];
const NON_ITEM_LINE =
/(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i;
function parseFirstMatchingAmount(text: string, patterns: RegExp[]): number | null {
for (const pattern of 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;
}
return null;
}
function parseAmount(text: string): number | null {
const labeledAmount = parseFirstMatchingAmount(text, AMOUNT_PATTERNS);
if (labeledAmount != null && labeledAmount > 0) return labeledAmount;
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
.map((match) => Number(match[1]!.replace(/,/g, "")))
.filter((amount) => Number.isFinite(amount) && amount > 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 parseLineItems(lines: string[]): ReceiptLineItem[] {
const items: ReceiptLineItem[] = [];
for (const [index, rawLine] of lines.entries()) {
const line = rawLine.replace(/\s+/g, " ").trim();
if (line.length < 5 || NON_ITEM_LINE.test(line)) continue;
const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/);
if (!match?.[1] || !match[2]) continue;
const amount = Number(match[2].replace(/,/g, ""));
const name = match[1].replace(/^\d+\s*[xX]\s+/, "").replace(/\s+\d+\s*[xX]\s*$/, "").trim();
if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue;
items.push({
id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`,
name: name.slice(0, 80),
amount,
rawLine,
});
}
return items.slice(0, 30);
}
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),
subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS),
tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS),
vendor: rawLines.find((line) => line.length >= 3)?.slice(0, 120) ?? null,
items: parseLineItems(rawLines),
rawLines,
};
}
+21
View File
@@ -0,0 +1,21 @@
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"
| "saved_no_client"
| "zero_hours";
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
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((value) => String(value).padStart(2, "0")).join(":");
}
export function formatElapsedHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}:${String(m).padStart(2, "0")}`;
}