Convert Beenvoice to a Turborepo monorepo
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# @beenvoice/domain
|
||||
|
||||
Platform-neutral Beenvoice rules shared by the web and mobile applications.
|
||||
|
||||
This package may use TypeScript and Web-standard APIs available in all target runtimes. It must not import Next.js, React DOM, React Native, database code, environment configuration, filesystem APIs, or native storage.
|
||||
|
||||
Current responsibilities:
|
||||
|
||||
- Expense category vocabulary
|
||||
- Invoice status calculation and transitions
|
||||
- Receipt-text parsing
|
||||
- Time-clock display primitives
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@beenvoice/domain",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./expense-categories": "./src/expense-categories.ts",
|
||||
"./invoice-status": "./src/invoice-status.ts",
|
||||
"./receipt-parse": "./src/receipt-parse.ts",
|
||||
"./time-clock": "./src/time-clock.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.14",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./expense-categories";
|
||||
export * from "./invoice-status";
|
||||
export * from "./receipt-parse";
|
||||
export * from "./time-clock";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EXPENSE_CATEGORIES,
|
||||
formatElapsedSeconds,
|
||||
getEffectiveInvoiceStatus,
|
||||
parseReceiptText,
|
||||
} from "../src";
|
||||
|
||||
describe("shared domain behavior", () => {
|
||||
test("exposes the expense category vocabulary", () => {
|
||||
expect(EXPENSE_CATEGORIES).toContain("Professional Services");
|
||||
});
|
||||
|
||||
test("calculates overdue status at day precision", () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
expect(getEffectiveInvoiceStatus("sent", yesterday)).toBe("overdue");
|
||||
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
|
||||
});
|
||||
|
||||
test("formats elapsed time", () => {
|
||||
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
|
||||
});
|
||||
|
||||
test("extracts receipt totals and line items", () => {
|
||||
const receipt = parseReceiptText(
|
||||
"Corner Store\nCoffee 3.50\nSubtotal 3.50\nTax 0.31\nTotal 3.81",
|
||||
);
|
||||
expect(receipt.amount).toBe(3.81);
|
||||
expect(receipt.tax).toBe(0.31);
|
||||
expect(receipt.items[0]?.name).toBe("Coffee");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user