Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'

git-subtree-dir: apps/web
git-subtree-mainline: 068a51b46b
git-subtree-split: 1e7174fa60
This commit is contained in:
2026-08-16 21:42:59 -04:00
350 changed files with 62192 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
export const APP_EMAIL_DOMAIN = "beenvoice.app";
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
+39
View File
@@ -0,0 +1,39 @@
/** Public app origin (no trailing slash). */
export function getAppUrl(): string {
if (typeof window !== "undefined") {
return window.location.origin.replace(/\/$/, "");
}
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return `http://localhost:${process.env.PORT ?? 3000}`;
}
/**
* Origin derived from an incoming request's own headers — robust against
* NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL drifting from the port the server is
* actually reachable on (e.g. local dev when the configured port is taken).
* Falls back to getAppUrl() if the request has no usable host header.
*/
export function getRequestOrigin(headers: Headers): string {
const host = headers.get("x-forwarded-host") ?? headers.get("host");
if (!host) return getAppUrl();
const forwardedProto = headers.get("x-forwarded-proto");
const protocol =
forwardedProto ?? (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")
? "http"
: "https");
return `${protocol}://${host}`;
}
/** Hostname for display (e.g. marketing browser chrome). */
export function getAppHost(): string {
try {
return new URL(getAppUrl()).host;
} catch {
return "beenvoice.app";
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import {
pdfFontFamilySchema,
type PdfFontFamily,
} from "~/lib/pdf-fonts";
export const colorModeValues = ["light", "dark", "system"] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const colorModeSchema = z.enum(colorModeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export { pdfFontFamilySchema, type PdfFontFamily };
export type ColorMode = z.infer<typeof colorModeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const defaultColorMode: ColorMode = "system";
export const defaultPdfSettings = {
pdfTemplate: "classic" as PdfTemplate,
pdfAccentColor: "#111827",
pdfFontFamily: "sans" as PdfFontFamily,
pdfNumericFontFamily: "mono" as PdfFontFamily,
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
+28
View File
@@ -0,0 +1,28 @@
import { db } from "~/server/db";
import { auditLog } from "~/server/db/schema";
export type AuditAction =
| "user.profile_updated"
| "user.role_updated"
| "user.password_reset_sent"
| "platform.pdf_settings_updated";
export type AuditTargetType = "user" | "platform";
type LogAuditEventInput = {
actorUserId: string;
action: AuditAction;
targetType: AuditTargetType;
targetId?: string;
metadata?: Record<string, unknown>;
};
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
await db.insert(auditLog).values({
actorUserId: input.actorUserId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadata: input.metadata,
});
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import { getAppUrl } from "~/lib/app-url";
function resolveAuthBaseUrl(): string | undefined {
return getAppUrl();
}
export const authClient = createAuthClient({
baseURL: resolveAuthBaseUrl(),
plugins: [genericOAuthClient()],
});
+88
View File
@@ -0,0 +1,88 @@
import { headers as nextHeaders } from "next/headers";
import { auth } from "~/lib/auth";
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
const MOBILE_SESSION_TOKEN_HEADER = "x-beenvoice-session-token";
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
const MAX_SESSION_TOKEN_LENGTH = 255;
const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
function looksLikeSessionCookie(cookie: string): boolean {
return cookie.split(";").some((part) => {
const name =
part
.trim()
.split("=", 1)[0]
?.replace(/^__Secure-/, "") ?? "";
return (
name === "better-auth.session_token" ||
name === "better-auth.session_data" ||
name.startsWith("better-auth.session_token.") ||
name.startsWith("better-auth.session_data.") ||
name.endsWith(".session_token") ||
name.endsWith(".session_data") ||
name.includes(".session_token.") ||
name.includes(".session_data.")
);
});
}
export function headersWithAuthCookieFallback(headers: Headers): Headers {
const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim();
if (
mobileCookie &&
mobileCookie.length <= MAX_AUTH_COOKIE_HEADER_LENGTH &&
looksLikeSessionCookie(mobileCookie)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set("cookie", mobileCookie);
return nextHeaders;
}
if (headers.get("cookie")?.trim()) return headers;
const sessionToken = headers.get(MOBILE_SESSION_TOKEN_HEADER)?.trim();
if (
sessionToken &&
sessionToken.length <= MAX_SESSION_TOKEN_LENGTH &&
SESSION_TOKEN_PATTERN.test(sessionToken)
) {
const nextHeaders = new Headers(headers);
nextHeaders.set(
"cookie",
[
`better-auth.session_token=${sessionToken}`,
`__Secure-better-auth.session_token=${sessionToken}`,
].join("; "),
);
return nextHeaders;
}
return headers;
}
export function hasSessionCookie(headers: Headers): boolean {
const cookie = headers.get("cookie") ?? "";
if (!cookie.trim()) return false;
return looksLikeSessionCookie(cookie);
}
export async function getOptionalServerSession(headers: Headers) {
headers = headersWithAuthCookieFallback(headers);
if (!hasSessionCookie(headers)) {
return null;
}
try {
return await auth.api.getSession({ headers });
} catch (error) {
console.error("[auth] Failed to resolve session:", error);
return null;
}
}
export async function getOptionalServerSessionFromHeaders() {
return getOptionalServerSession(await nextHeaders());
}
+146
View File
@@ -0,0 +1,146 @@
import { expo } from "@better-auth/expo";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { env } from "~/env";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { sendPasswordResetEmail } from "~/lib/password-reset";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
const authentikEnabled = Boolean(
process.env.AUTHENTIK_ISSUER &&
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
const signupsDisabled = env.DISABLE_SIGNUPS;
// Derive the authentik origin from the issuer URL so the OAuth callback is
// automatically trusted without needing a separate AUTHENTIK_ORIGIN env var.
const authentikOrigin =
authentikEnabled && process.env.AUTHENTIK_ISSUER
? new URL(process.env.AUTHENTIK_ISSUER).origin
: null;
const staticTrustedOrigins = [
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
"beenvoice://",
...(env.NODE_ENV === "development"
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
: []),
...(authentikOrigin ? [authentikOrigin] : []),
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
];
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
secret: process.env.AUTH_SECRET,
advanced: {
trustedProxyHeaders: true,
// Login from a LAN IP, ngrok tunnel, or the Expo dev client hits the API
// from an Origin that's rarely worth adding to trustedOrigins ahead of
// time. Skip the Origin/CSRF check in dev only; production still
// enforces it via trustedOrigins above.
...(env.NODE_ENV === "development" ? { disableCSRFCheck: true } : {}),
},
rateLimit: {
enabled: true,
window: 60,
max: 100,
customRules: {
"/sign-in/email": {
window: 60,
max: 10,
},
"/sign-up/email": {
window: 60 * 60,
max: 5,
},
"/request-password-reset": {
window: 60 * 60,
max: 5,
},
"/reset-password": {
window: 60,
max: 10,
},
},
},
experimental: {
joins: true,
},
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verificationTokens,
},
}),
databaseHooks: {
user: {
create: {
after: async (user) => {
if (isDemoUser(user)) {
return;
}
await promoteFirstRealUserIfNeeded(user.id);
},
},
},
},
trustedOrigins: staticTrustedOrigins,
...(authentikEnabled && {
accountLinking: {
enabled: true,
trustedProviders: ["authentik"],
},
}),
emailAndPassword: {
enabled: true,
disableSignUp: signupsDisabled,
minPasswordLength: 8,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, token }) => {
await sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken: token,
});
},
password: {
hash: async (password) => {
const bcrypt = await import("bcryptjs");
return bcrypt.hash(password, 12);
},
verify: async ({ hash, password }) => {
const bcrypt = await import("bcryptjs");
return bcrypt.compare(password, hash);
},
},
},
plugins: [
expo(),
nextCookies(),
...(authentikEnabled
? [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"],
pkce: true,
},
],
}),
]
: []),
],
});
+56
View File
@@ -0,0 +1,56 @@
import { env } from "~/env";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
export {
colorModeSchema,
defaultColorMode,
defaultPdfSettings,
pdfFontFamilySchema,
pdfTemplateSchema,
} from "~/lib/appearance";
export const colorModes: {
value: ColorMode;
label: string;
description: string;
}[] = [
{
value: "system",
label: "System",
description: "Match your device light or dark setting.",
},
{
value: "light",
label: "Light",
description: "Always use light mode.",
},
{
value: "dark",
label: "Dark",
description: "Always use dark mode.",
},
];
export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline:
env.NEXT_PUBLIC_BRAND_TAGLINE ??
"Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
export function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
+113
View File
@@ -0,0 +1,113 @@
export function hexToRgb(hex: string) {
const normalized = normalizeHex(hex).slice(1, 7);
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
export function rgbToHex(r: number, g: number, b: number) {
return `#${[r, g, b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function rgbToHsl(r: number, g: number, b: number) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(lightness * 100) };
}
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
const hue =
max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: Math.round(saturation * 100),
l: Math.round(lightness * 100),
};
}
export function hslToRgb(h: number, s: number, l: number) {
const hue = clamp(h, 0, 360);
const saturation = clamp(s, 0, 100) / 100;
const lightness = clamp(l, 0, 100) / 100;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = lightness - c / 2;
const [red, green, blue] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return {
r: Math.round((red + m) * 255),
g: Math.round((green + m) * 255),
b: Math.round((blue + m) * 255),
};
}
export function hexToRgba(hex: string) {
const normalized = normalizeHex(hex, true);
const rgb = hexToRgb(normalized);
const alphaHex = normalized.length === 9 ? normalized.slice(7, 9) : "ff";
return {
...rgb,
a: Number((parseInt(alphaHex, 16) / 255).toFixed(2)),
};
}
export function rgbaToHex(r: number, g: number, b: number, a: number) {
const alpha = clamp(Math.round(clampAlpha(a) * 255), 0, 255)
.toString(16)
.padStart(2, "0");
return `${rgbToHex(r, g, b)}${alpha}`.toUpperCase();
}
export function rgbaToHsla(r: number, g: number, b: number, a: number) {
return { ...rgbToHsl(r, g, b), a: clampAlpha(a) };
}
export function hslaToRgba(h: number, s: number, l: number, a: number) {
return { ...hslToRgb(h, s, l), a: clampAlpha(a) };
}
function normalizeHex(hex: string, alpha = false) {
const fallback = alpha ? "#FFFFFFff" : "#FFFFFF";
const withHash = hex.startsWith("#") ? hex : `#${hex}`;
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) return withHash;
if (alpha && /^#[0-9A-Fa-f]{8}$/.test(withHash)) return withHash;
return fallback;
}
function clamp(value: number, min: number, max: number) {
return Math.max(
min,
Math.min(max, Math.floor(Number.isFinite(value) ? value : min)),
);
}
function clampAlpha(value: number) {
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
}
+274
View File
@@ -0,0 +1,274 @@
type Oklch = {
l: number;
c: number;
h: number;
};
/**
* Converts a hexadecimal color string to an Oklch color object.
*
* @param {string} hex - The hexadecimal color string (e.g., "#RRGGBB", "RRGGBB", "#RGB", "RGB").
* @returns {Oklch} The Oklch color object.
* @throws {Error} If the hex color format is invalid.
*/
export function hexToOklch(hex: string): Oklch {
const rgb = hexToRgb(hex);
const linear_rgb = rgb.map(srgbToLinearRgb) as [number, number, number];
const xyz = linearRgbToXyz(linear_rgb);
const oklab = xyzToOklab(xyz);
const oklch = oklabToOklch(oklab);
return {
l: oklch[0] || 0,
c: oklch[1] || 0,
h: oklch[2] || 0,
};
}
export function generateAccentColors(hex: string) {
const base = hexToOklch(hex);
const light = {
"--background": `oklch(0.99 ${base.c * 0.05} ${base.h})`,
"--foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--card": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--card-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
"--muted-foreground": `oklch(0.5 ${base.c * 0.4} ${base.h})`,
"--accent": `oklch(0.98 ${base.c * 0.6} ${base.h})`,
"--accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--destructive": "oklch(0.58 0.24 28)",
"--destructive-foreground": "oklch(0.98 0.01 230)",
"--success": "oklch(0.55 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.65 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--input": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--ring": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--sidebar-ring": `oklch(0.6 ${base.c} ${base.h})`,
"--navbar": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--navbar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--navbar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
};
const dark = {
"--background": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--card": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--card-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
"--accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--destructive": "oklch(0.7 0.19 22)",
"--destructive-foreground": "oklch(0.2 0.03 230)",
"--success": "oklch(0.6 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.7 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--input": `oklch(0.35 ${base.c * 0.4} ${base.h})`,
"--ring": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--sidebar-ring": `oklch(0.7 ${base.c} ${base.h})`,
"--navbar": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--navbar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--navbar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
};
return { light, dark };
}
/**
* Converts a hexadecimal color string to an array of R, G, B components (0-255).
* Supports "#RRGGBB", "RRGGBB", "#RGB", "RGB" formats.
* @param {string} hex - The hexadecimal color string.
* @returns {number[]} An array [r, g, b].
* @throws {Error} If the hex color format is invalid.
*/
function hexToRgb(hex: string): [number, number, number] {
let r = 0,
g = 0,
b = 0;
// Remove '#' if present
if (hex.startsWith("#")) {
hex = hex.slice(1);
}
// Handle 3-digit hex (e.g., "F0C" -> "FF00CC")
if (hex.length === 3) {
const chars = hex.split("");
if (
chars.length === 3 &&
chars.every((char) => /^[0-9A-Fa-f]$/.test(char))
) {
r = parseInt(chars[0]! + chars[0]!, 16);
g = parseInt(chars[1]! + chars[1]!, 16);
b = parseInt(chars[2]! + chars[2]!, 16);
} else {
throw new Error("Invalid 3-digit hex color format.");
}
}
// Handle 6-digit hex (e.g., "FF00CC")
else if (hex.length === 6) {
const rStr = hex.substring(0, 2);
const gStr = hex.substring(2, 4);
const bStr = hex.substring(4, 6);
if (
/^[0-9A-Fa-f]{2}$/.test(rStr) &&
/^[0-9A-Fa-f]{2}$/.test(gStr) &&
/^[0-9A-Fa-f]{2}$/.test(bStr)
) {
r = parseInt(rStr, 16);
g = parseInt(gStr, 16);
b = parseInt(bStr, 16);
} else {
throw new Error("Invalid 6-digit hex color format.");
}
} else {
throw new Error("Invalid hex color format. Use #RRGGBB or #RGB.");
}
return [r, g, b];
}
/**
* Converts an sRGB component (0-255) to a linear sRGB component (0-1).
* @param {number} c - The sRGB component value (0-255).
* @returns {number} The linear sRGB component value (0-1).
*/
function srgbToLinearRgb(c: number) {
c /= 255; // Normalize to [0, 1]
// Apply the sRGB gamma correction formula.
if (c <= 0.04045) {
return c / 12.92;
} else {
return Math.pow((c + 0.055) / 1.055, 2.4);
}
}
/**
* Multiplies a 3x3 matrix by a 3-element vector.
* @param {number[][]} matrix - The 3x3 matrix.
* @param {number[]} vector - The 3-element vector.
* @returns {number[]} The resulting 3-element vector.
*/
function multiplyMatrix(
matrix: number[][],
vector: number[],
): [number, number, number] {
const result = new Array(matrix.length).fill(0) as number[];
for (let i = 0; i < matrix.length; i++) {
for (let j = 0; j < vector.length; j++) {
result[i]! += matrix[i]![j]! * vector[j]!;
}
}
return [result[0]!, result[1]!, result[2]!];
}
/**
* Converts linear sRGB values to CIE XYZ values (D65 white point).
* @param {number[]} rgb_linear - An array [r, g, b] of linear sRGB components (0-1).
* @returns {number[]} An array [X, Y, Z] of CIE XYZ components.
*/
function linearRgbToXyz(
rgb_linear: [number, number, number],
): [number, number, number] {
// Standard sRGB to XYZ D65 conversion matrix.
const M_srgb_to_xyz = [
[0.4123908, 0.35758434, 0.18048079],
[0.21263901, 0.71516868, 0.07219232],
[0.01933082, 0.11919478, 0.95053215],
];
return multiplyMatrix(M_srgb_to_xyz, rgb_linear);
}
/**
* Converts CIE XYZ values to Oklab values.
* @param {number[]} xyz - An array [X, Y, Z] of CIE XYZ components.
* @returns {number[]} An array [L, a, b] of Oklab components.
*/
function xyzToOklab(xyz: [number, number, number]): [number, number, number] {
// Convert XYZ to LMS (linear cone responses).
const M_xyz_to_lms = [
[0.81890226, 0.03298366, 0.05591174],
[0.36186742, 0.638518, 0.00083942],
[0, 0, 0.82521],
];
const lms = multiplyMatrix(M_xyz_to_lms, xyz);
// Apply cube root non-linearity to LMS values.
const lms_prime = lms.map((val) => Math.cbrt(val)) as [
number,
number,
number,
];
// Convert LMS' to Oklab.
const M_lms_prime_to_oklab = [
[0.2104542553, 0.793617785, -0.0040720468],
[1.9779984951, -2.428592205, 0.4505937099],
[0.0259040371, 0.7827717662, -0.808675766],
];
return multiplyMatrix(M_lms_prime_to_oklab, lms_prime);
}
/**
* Converts Oklab values to Oklch values.
* @param {number[]} oklab - An array [L, a, b] of Oklab components (L in 0-1).
* @returns {number[]} An array [L, C, h] of Oklch components (L in 0-100, h in degrees).
*/
function oklabToOklch(oklab: number[]): [number, number, number] {
const L = oklab[0] ?? 0; // Oklab L is 0-1
const a = oklab[1] ?? 0;
const b = oklab[2] ?? 0;
const C = Math.sqrt(a * a + b * b); // Chroma
let h = Math.atan2(b, a) * (180 / Math.PI); // Hue in degrees
// Normalize hue to [0, 360)
if (h < 0) {
h += 360;
}
// Oklch L is typically scaled to 0-100.
return [L, C, h];
}
+30
View File
@@ -0,0 +1,30 @@
export const SUPPORTED_CURRENCIES = [
{ code: "USD", label: "USD US Dollar" },
{ code: "EUR", label: "EUR Euro" },
{ code: "GBP", label: "GBP British Pound" },
{ code: "CAD", label: "CAD Canadian Dollar" },
{ code: "AUD", label: "AUD Australian Dollar" },
{ code: "NZD", label: "NZD New Zealand Dollar" },
{ code: "CHF", label: "CHF Swiss Franc" },
{ code: "JPY", label: "JPY Japanese Yen" },
{ code: "SGD", label: "SGD Singapore Dollar" },
{ code: "HKD", label: "HKD Hong Kong Dollar" },
{ code: "SEK", label: "SEK Swedish Krona" },
{ code: "NOK", label: "NOK Norwegian Krone" },
{ code: "DKK", label: "DKK Danish Krone" },
{ code: "MXN", label: "MXN Mexican Peso" },
{ code: "BRL", label: "BRL Brazilian Real" },
{ code: "INR", label: "INR Indian Rupee" },
{ code: "ZAR", label: "ZAR South African Rand" },
] as const;
export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]["code"];
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
+44
View File
@@ -0,0 +1,44 @@
const DATABASE_SETUP_HINT =
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectErrorParts(error: unknown): string[] {
const parts: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current && isRecord(current) && !seen.has(current)) {
seen.add(current);
if (typeof current.message === "string") {
parts.push(current.message);
}
if (typeof current.code === "string") {
parts.push(current.code);
}
current = current.cause;
}
return parts;
}
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
const haystack = collectErrorParts(error).join(" ").toLowerCase();
if (
haystack.includes("does not exist") ||
haystack.includes("42p01") ||
haystack.includes("econnrefused") ||
haystack.includes("connection refused") ||
haystack.includes("connect econnrefused")
) {
return DATABASE_SETUP_HINT;
}
return null;
}
+11
View File
@@ -0,0 +1,11 @@
/** Default invoice number format (matches web/mobile create forms). */
export function generateInvoiceNumber(now = new Date()): string {
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
}
export function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
@@ -0,0 +1,2 @@
export { generateInvoiceEmailTemplate } from "./invoice-email";
export { generatePasswordResetEmailTemplate } from "./password-reset-email";
@@ -0,0 +1,586 @@
import { getAppUrl } from "~/lib/app-url";
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so
// non-raster logos are requested through the same on-the-fly PNG
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
baseUrl: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
interface InvoiceEmailTemplateProps {
invoice: {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
currency?: string | null;
client: {
name: string;
email: string | null;
};
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
} | null;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}>;
};
customContent?: string;
customMessage?: string;
userName?: string;
userEmail?: string;
baseUrl?: string;
}
export function generateInvoiceEmailTemplate({
invoice,
customContent,
customMessage,
userName,
userEmail,
baseUrl = getAppUrl(),
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: invoice.currency ?? "USD",
}).format(amount);
};
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
const businessAddress = invoice.business
? [
invoice.business.addressLine1,
invoice.business.addressLine2,
invoice.business.city && invoice.business.state
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode ?? ""}`.trim()
: (invoice.business.city ?? invoice.business.state),
invoice.business.country !== "United States"
? invoice.business.country
: null,
]
.filter(Boolean)
.join("<br>")
: "";
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="format-detection" content="telephone=no">
<meta name="format-detection" content="date=no">
<meta name="format-detection" content="address=no">
<meta name="format-detection" content="email=no">
<title>Invoice ${invoice.invoiceNumber}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
line-height: 1.6;
color: #0f0f0f;
background-color: #f9fafb;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 0;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.header {
background: #0f0f0f;
padding: 32px 24px;
text-align: center;
color: white;
}
.header-content {
font-size: 28px;
font-weight: bold;
margin-bottom: 8px;
letter-spacing: 0.5px;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.header-subtitle {
font-size: 16px;
opacity: 0.8;
font-weight: normal;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.content {
padding: 32px 24px;
}
.greeting {
font-size: 16px;
font-weight: bold;
margin-bottom: 24px;
color: #0f0f0f;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.message {
font-size: 15px;
line-height: 1.7;
margin-bottom: 32px;
color: #374151;
}
.invoice-card {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0;
padding: 16px;
margin: 24px 0;
}
.invoice-summary {
margin-bottom: 20px;
}
.invoice-number {
font-size: 24px;
font-weight: bold;
color: #0f0f0f;
margin-bottom: 8px;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.invoice-date {
font-size: 14px;
color: #6b7280;
margin-bottom: 4px;
}
.invoice-details {
border-top: 1px solid #e5e7eb;
padding-top: 20px;
margin-top: 20px;
}
.detail-row {
border-collapse: separate;
border-spacing: 0;
width: 100%;
border-bottom: 1px solid #f3f4f6;
}
.detail-row:last-child {
border-bottom: none;
font-weight: 600;
padding-top: 12px;
border-top: 2px solid #e5e7eb;
margin-top: 8px;
}
.detail-label {
font-size: 14px;
color: #6b7280;
text-align: left;
padding: 8px 0;
}
.detail-value {
font-size: 14px;
color: #1f2937;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
text-align: right;
padding: 8px 0;
}
.business-info {
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.business-name {
font-size: 16px;
font-weight: bold;
color: #0f0f0f;
margin-bottom: 8px;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.business-details {
font-size: 14px;
color: #6b7280;
line-height: 1.5;
}
.custom-content ul {
margin: 16px 0;
padding-left: 24px;
}
.custom-content li {
margin: 8px 0;
padding-left: 4px;
}
.cta-section {
text-align: center;
margin: 32px 0;
padding: 24px;
background-color: #f9fafb;
border-radius: 0;
}
.cta-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 16px;
}
.attachment-notice {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0;
padding: 16px;
margin: 20px 0;
display: flex;
align-items: center;
gap: 12px;
}
.attachment-icon {
width: 20px;
height: 20px;
background-color: #374151;
border-radius: 0;
flex-shrink: 0;
}
.attachment-text {
font-size: 14px;
color: #374151;
font-weight: bold;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.signature {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.signature-name {
font-size: 16px;
font-weight: bold;
color: #0f0f0f;
margin-bottom: 4px;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
.signature-email {
font-size: 14px;
color: #6b7280;
}
.footer {
background-color: #f9fafb;
padding: 24px;
text-align: center;
border-top: 1px solid #e5e7eb;
}
.footer-brand {
font-size: 18px;
font-weight: bold;
color: #0f0f0f;
margin: 0 auto 8px;
display: block;
text-align: center;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
letter-spacing: 0.5px;
}
.footer-text {
font-size: 12px;
color: #6b7280;
line-height: 1.5;
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
}
/* Email client specific fixes */
@media screen and (max-width: 600px) {
.email-container {
width: 100% !important;
max-width: 600px !important;
}
}
/* Outlook specific fixes */
table {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
/* Gmail specific fixes */
.gmail-fix {
border-collapse: separate !important;
border-spacing: 0 !important;
}
/* Apple Mail attachment preview fix */
.attachment-notice {
border: 1px solid #e5e7eb !important;
background-color: #f9fafb !important;
}
@media (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding-left: 16px;
padding-right: 16px;
}
.invoice-header {
flex-direction: column;
align-items: flex-start;
}
.invoice-amount {
text-align: left;
}
.detail-row td {
display: block !important;
width: 100% !important;
text-align: left !important;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
${logoUrl ? `<img src="${logoUrl}" alt="${invoice.business?.name ?? ""}" style="max-height: 40px; max-width: 200px; margin-bottom: 12px;">` : ""}
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
</div>
<div class="content">
<div class="message">
<div class="greeting">${getTimeOfDayGreeting()},</div>
<p>I hope this email finds you well. Please find attached invoice <strong>#${invoice.invoiceNumber}</strong>
for the services provided. The invoice details are summarized below for your reference.</p>
${customMessage ? `<div style="margin: 16px 0; padding: 16px; background-color: #f9fafb; border-left: 4px solid #374151; border-radius: 0;">${customMessage}</div>` : ""}
</div>
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
<div class="invoice-card">
<div class="invoice-summary">
<div class="invoice-number">#${invoice.invoiceNumber}</div>
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
</div>
<div class="invoice-details">
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
<tr>
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Client</td>
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${invoice.client.name}</td>
</tr>
</table>
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
<tr>
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Subtotal</td>
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(subtotal)}</td>
</tr>
</table>
${
invoice.taxRate > 0
? `<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
<tr>
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Tax (${invoice.taxRate}%)</td>
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(taxAmount)}</td>
</tr>
</table>`
: ""
}
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-top: 2px solid #e5e7eb; margin-top: 8px; padding-top: 12px;">
<tr>
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left; font-weight: bold; font-size: 16px;">Total</td>
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right; font-weight: bold; font-size: 18px; color: #0f0f0f;">${formatCurrency(total)}</td>
</tr>
</table>
</div>
</div>
<div class="attachment-notice">
<div class="attachment-icon"></div>
<div class="attachment-text">
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
</div>
</div>
<div class="cta-section">
<div class="cta-text">
If you have any questions about this invoice, please don't hesitate to reach out.
Thank you for your business!
</div>
</div>
${
!customContent
? `<div class="signature">
<div class="signature-name">${userName ?? invoice.business?.name ?? "Best regards"}</div>
${userEmail ? `<div class="signature-email">${userEmail}</div>` : ""}
</div>`
: ""
}
</div>
<div class="footer">
<div class="footer-brand">beenvoice</div>
${
invoice.business
? `<div class="footer-text">
<strong>${invoice.business.name}</strong><br>
${invoice.business.email ? `${invoice.business.email}<br>` : ""}
${invoice.business.phone ? `${invoice.business.phone}<br>` : ""}
${businessAddress ? `${businessAddress}` : ""}
</div>`
: `<div class="footer-text">
Professional invoicing for modern businesses
</div>`
}
</div>
</div>
</body>
</html>`;
// Generate plain text version
const text = `
${getTimeOfDayGreeting()},
I hope this email finds you well. Please find attached invoice #${invoice.invoiceNumber} for the services provided.${
customMessage
? `\n\n${customMessage
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim()}`
: ""
}${
customContent
? `\n\n${customContent
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim()}`
: ""
}
INVOICE DETAILS
═══════════════
Invoice Number: #${invoice.invoiceNumber}
Issue Date: ${formatDate(invoice.issueDate)}
Due Date: ${formatDate(invoice.dueDate)}
Client: ${invoice.client.name}
AMOUNT BREAKDOWN
═══════════════
Subtotal: ${formatCurrency(subtotal)}${
invoice.taxRate > 0
? `\nTax (${invoice.taxRate}%): ${formatCurrency(taxAmount)}`
: ""
}
Total: ${formatCurrency(total)}
ATTACHMENT
═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
If you have any questions about this invoice, please don't hesitate to reach out.
Thank you for your business!
${userName ?? invoice.business?.name ?? "Best regards"}${
userEmail ? `\n${userEmail}` : ""
}
---
${
invoice.business
? `${invoice.business.name}${invoice.business.email ? `\n${invoice.business.email}` : ""}${
invoice.business.phone ? `\n${invoice.business.phone}` : ""
}${businessAddress ? `\n${businessAddress.replace(/<br>/g, "\n")}` : ""}`
: "Professional invoicing for modern businesses"
}
`.trim();
return { html, text };
}
@@ -0,0 +1,232 @@
import { formatEmailDate } from "src/lib/email-utils";
import { SUPPORT_EMAIL } from "~/lib/app-email";
interface PasswordResetEmailProps {
userEmail: string;
userName?: string;
resetToken: string;
resetUrl: string;
expiryHours?: number;
}
export function generatePasswordResetEmailTemplate({
userEmail,
userName,
resetUrl,
expiryHours = 24,
}: PasswordResetEmailProps) {
const displayName = userName ?? userEmail.split("@")[0];
const currentDate = formatEmailDate(new Date());
// HTML version
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Reset - beenvoice</title>
<style>
body {
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
line-height: 1.6;
color: #0f0f0f;
background-color: #f9fafb;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.header {
background-color: #0f0f0f;
padding: 32px 32px 24px;
border-bottom: 1px solid #e5e7eb;
}
.logo {
font-size: 24px;
font-weight: 700;
color: #ffffff;
margin: 0;
letter-spacing: 0.5px;
}
.content {
padding: 32px;
}
.title {
font-size: 20px;
font-weight: 600;
margin: 0 0 16px 0;
color: #0f0f0f;
}
.text {
margin: 0 0 24px 0;
color: #525252;
font-size: 14px;
}
.button-container {
margin: 32px 0;
text-align: center;
}
.button {
display: inline-block;
background-color: #374151;
color: #ffffff;
padding: 12px 24px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
border: 1px solid #374151;
border-radius: 0;
transition: background-color 0.2s;
}
.button:hover {
background-color: #1f2937;
border-color: #1f2937;
}
.security-notice {
background-color: #f9fafb;
border-left: 4px solid #374151;
padding: 16px;
margin: 24px 0;
border-radius: 0;
}
.security-notice h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #0f0f0f;
}
.security-notice p {
margin: 0;
font-size: 13px;
color: #525252;
}
.footer {
background-color: #f9fafb;
padding: 24px 32px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}
.footer a {
color: #374151;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.divider {
height: 1px;
background-color: #e5e7eb;
margin: 24px 0;
}
@media (max-width: 600px) {
.container {
margin: 0;
border: none;
}
.header, .content, .footer {
padding: 24px 16px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="logo">
beenvoice
</h1>
</div>
<div class="content">
<h2 class="title">Reset Your Password</h2>
<p class="text">Hello ${displayName},</p>
<p class="text">
We received a request to reset the password for your beenvoice account.
If you made this request, click the button below to set a new password.
</p>
<div class="button-container">
<a href="${resetUrl}" class="button">Reset Your Password</a>
</div>
<p class="text">
If the button doesn't work, copy and paste this link into your browser:
<br>
<a href="${resetUrl}" style="color: #374151; word-break: break-all;">${resetUrl}</a>
</p>
<div class="security-notice">
<h4>Security Information</h4>
<p>This password reset link will expire in ${expiryHours} hours for your security.</p>
<p>If you didn't request this password reset, you can safely ignore this email.</p>
</div>
<div class="divider"></div>
<p class="text">
If you're having trouble accessing your account or have questions,
please contact our support team.
</p>
<p class="text">
Best regards,<br>
The beenvoice Team
</p>
</div>
<div class="footer">
<p>
This email was sent to <strong>${userEmail}</strong> on ${currentDate}.
</p>
<p>
beenvoice - Professional invoicing made simple<br>
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
</p>
</div>
</div>
</body>
</html>`;
// Plain text version
const text = `
beenvoice - Password Reset
Hello ${displayName},
We received a request to reset the password for your beenvoice account.
To reset your password, please visit this link:
${resetUrl}
SECURITY INFORMATION:
- This link will expire in ${expiryHours} hours
- If you didn't request this reset, you can safely ignore this email
- Never share this link with anyone
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
Best regards,
The beenvoice Team
---
This email was sent to ${userEmail} on ${currentDate}.
beenvoice - Professional invoicing made simple
`;
return {
html: html.trim(),
text: text.trim(),
subject: "Reset Your beenvoice Password",
};
}
@@ -0,0 +1,135 @@
interface ReminderEmailTemplateProps {
invoice: {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
totalAmount: number;
currency?: string | null;
client: { name: string; email: string | null };
business?: {
name: string;
nickname?: string | null;
email?: string | null;
} | null;
};
customMessage?: string;
userName?: string;
userEmail?: string;
}
export function generateReminderEmailTemplate({
invoice,
customMessage,
userName,
userEmail,
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
new Date(date),
);
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: invoice.currency ?? "USD",
}).format(amount);
const senderName =
invoice.business?.name
? invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: userName ?? "Your service provider";
const isOverdue = new Date(invoice.dueDate) < new Date();
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber}${formatCurrency(invoice.totalAmount)}`;
const defaultMessage = isOverdue
? `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} was due on ${formatDate(invoice.dueDate)} and remains outstanding. Please arrange payment at your earliest convenience.`
: `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} is due on ${formatDate(invoice.dueDate)}. Please ensure payment is arranged before the due date.`;
const bodyMessage = customMessage ?? defaultMessage;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Payment Reminder</title>
</head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
<!-- Header -->
<tr><td style="background:#111827;padding:24px 32px;">
<p style="margin:0;color:#f9fafb;font-size:20px;font-weight:700;">${senderName}</p>
${userEmail ? `<p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">${userEmail}</p>` : ""}
</td></tr>
<!-- Badge -->
<tr><td style="padding:24px 32px 0;">
<span style="display:inline-block;background:${isOverdue ? "#fef2f2" : "#fffbeb"};color:${isOverdue ? "#dc2626" : "#d97706"};border:1px solid ${isOverdue ? "#fecaca" : "#fde68a"};border-radius:6px;padding:4px 12px;font-size:12px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;">
${isOverdue ? "OVERDUE" : "PAYMENT DUE"}
</span>
</td></tr>
<!-- Body -->
<tr><td style="padding:24px 32px;">
<p style="margin:0 0 16px;color:#374151;font-size:15px;">Dear ${invoice.client.name},</p>
<p style="margin:0 0 24px;color:#374151;font-size:15px;line-height:1.6;">${bodyMessage}</p>
<!-- Invoice details box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:24px;">
<tr><td style="padding:16px 20px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Invoice number</td>
<td style="color:#111827;font-size:13px;font-weight:600;text-align:right;padding:4px 0;">${invoice.invoiceNumber}</td>
</tr>
<tr>
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Issue date</td>
<td style="color:#111827;font-size:13px;text-align:right;padding:4px 0;">${formatDate(invoice.issueDate)}</td>
</tr>
<tr>
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Due date</td>
<td style="color:${isOverdue ? "#dc2626" : "#111827"};font-size:13px;font-weight:${isOverdue ? "600" : "400"};text-align:right;padding:4px 0;">${formatDate(invoice.dueDate)}</td>
</tr>
<tr><td colspan="2" style="border-top:1px solid #e5e7eb;padding:8px 0 0;"></td></tr>
<tr>
<td style="color:#111827;font-size:15px;font-weight:700;padding:4px 0;">Amount due</td>
<td style="color:#111827;font-size:15px;font-weight:700;text-align:right;padding:4px 0;">${formatCurrency(invoice.totalAmount)}</td>
</tr>
</table>
</td></tr>
</table>
<p style="margin:0;color:#6b7280;font-size:13px;">If you have already made payment, please disregard this notice. Thank you for your business.</p>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f9fafb;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
<p style="margin:0;color:#9ca3af;font-size:12px;">Sent by ${senderName} · Powered by beenvoice</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = `Payment Reminder from ${senderName}
Dear ${invoice.client.name},
${bodyMessage}
Invoice: ${invoice.invoiceNumber}
Issue date: ${formatDate(invoice.issueDate)}
Due date: ${formatDate(invoice.dueDate)}
Amount due: ${formatCurrency(invoice.totalAmount)}
If you have already made payment, please disregard this notice.
Thank you for your business.
${senderName}`;
return { html, text, subject };
}
+84
View File
@@ -0,0 +1,84 @@
import { generateInvoiceEmailTemplate } from "./email-templates";
// Simple test utility to verify the email template works
export function testEmailTemplate() {
const mockInvoice = {
invoiceNumber: "INV-001",
issueDate: new Date(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
status: "draft",
totalAmount: 1000,
taxRate: 8.5,
notes: null,
client: {
name: "Test Client",
email: "client@example.com",
},
business: {
name: "Test Business",
email: "business@example.com",
phone: "(555) 123-4567",
addressLine1: "123 Business St",
addressLine2: null,
city: "Business City",
state: "CA",
postalCode: "12345",
country: "United States",
},
items: [
{
date: new Date(),
description: "Development Services",
hours: 10,
rate: 100,
amount: 1000,
},
],
};
try {
const template = generateInvoiceEmailTemplate({
invoice: mockInvoice,
userName: "John Doe",
userEmail: "john@example.com",
});
return {
success: true,
hasHtml: !!template.html,
hasText: !!template.text,
htmlLength: template.html.length,
textLength: template.text.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
// Format currency for display
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
// Format date for email display
export function formatEmailDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}
// Get time-based greeting
export function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
}
+6
View File
@@ -0,0 +1,6 @@
/** Parse env vars that Docker Compose passes as strings ("true" / "false"). */
export function envBoolean(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+82
View File
@@ -0,0 +1,82 @@
import { and, asc, eq, ne, sql } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
export const DEMO_USER_EMAIL = "demo@example.com";
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
export function isDemoUser(user: {
email?: string | null;
id?: string | null;
}): boolean {
const email = user.email?.toLowerCase();
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
}
function nonDemoUserConditions() {
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
}
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
);
}
/**
* Role for a user about to be inserted. Call inside a transaction before insert.
*/
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
await acquireFirstUserAdminLock(tx);
const [result] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(nonDemoUserConditions());
return (result?.count ?? 0) === 0 ? "admin" : "user";
}
/**
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
*/
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
await db.transaction(async (tx) => {
await acquireFirstUserAdminLock(tx);
const user = await tx.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, role: true },
});
if (!user || isDemoUser(user)) {
return;
}
const [adminResult] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
if ((adminResult?.count ?? 0) > 0) {
return;
}
const [firstRealUser] = await tx
.select({ id: users.id })
.from(users)
.where(nonDemoUserConditions())
.orderBy(asc(users.createdAt))
.limit(1);
if (firstRealUser?.id === userId) {
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
}
});
}
+388
View File
@@ -0,0 +1,388 @@
/**
* Shared form constants and utilities
*/
// US States
export const US_STATES = [
{ value: "AL", label: "Alabama" },
{ value: "AK", label: "Alaska" },
{ value: "AZ", label: "Arizona" },
{ value: "AR", label: "Arkansas" },
{ value: "CA", label: "California" },
{ value: "CO", label: "Colorado" },
{ value: "CT", label: "Connecticut" },
{ value: "DE", label: "Delaware" },
{ value: "FL", label: "Florida" },
{ value: "GA", label: "Georgia" },
{ value: "HI", label: "Hawaii" },
{ value: "ID", label: "Idaho" },
{ value: "IL", label: "Illinois" },
{ value: "IN", label: "Indiana" },
{ value: "IA", label: "Iowa" },
{ value: "KS", label: "Kansas" },
{ value: "KY", label: "Kentucky" },
{ value: "LA", label: "Louisiana" },
{ value: "ME", label: "Maine" },
{ value: "MD", label: "Maryland" },
{ value: "MA", label: "Massachusetts" },
{ value: "MI", label: "Michigan" },
{ value: "MN", label: "Minnesota" },
{ value: "MS", label: "Mississippi" },
{ value: "MO", label: "Missouri" },
{ value: "MT", label: "Montana" },
{ value: "NE", label: "Nebraska" },
{ value: "NV", label: "Nevada" },
{ value: "NH", label: "New Hampshire" },
{ value: "NJ", label: "New Jersey" },
{ value: "NM", label: "New Mexico" },
{ value: "NY", label: "New York" },
{ value: "NC", label: "North Carolina" },
{ value: "ND", label: "North Dakota" },
{ value: "OH", label: "Ohio" },
{ value: "OK", label: "Oklahoma" },
{ value: "OR", label: "Oregon" },
{ value: "PA", label: "Pennsylvania" },
{ value: "RI", label: "Rhode Island" },
{ value: "SC", label: "South Carolina" },
{ value: "SD", label: "South Dakota" },
{ value: "TN", label: "Tennessee" },
{ value: "TX", label: "Texas" },
{ value: "UT", label: "Utah" },
{ value: "VT", label: "Vermont" },
{ value: "VA", label: "Virginia" },
{ value: "WA", label: "Washington" },
{ value: "WV", label: "West Virginia" },
{ value: "WI", label: "Wisconsin" },
{ value: "WY", label: "Wyoming" },
];
// Most commonly used countries
export const POPULAR_COUNTRIES = [
{ value: "United States", label: "United States" },
{ value: "United Kingdom", label: "United Kingdom" },
{ value: "Canada", label: "Canada" },
{ value: "Australia", label: "Australia" },
{ value: "Germany", label: "Germany" },
{ value: "France", label: "France" },
{ value: "India", label: "India" },
{ value: "Japan", label: "Japan" },
{ value: "Mexico", label: "Mexico" },
{ value: "Brazil", label: "Brazil" },
];
// All countries with ISO codes
export const ALL_COUNTRIES = [
{ value: "Afghanistan", label: "Afghanistan" },
{ value: "Albania", label: "Albania" },
{ value: "Algeria", label: "Algeria" },
{ value: "Andorra", label: "Andorra" },
{ value: "Angola", label: "Angola" },
{ value: "Antigua and Barbuda", label: "Antigua and Barbuda" },
{ value: "Argentina", label: "Argentina" },
{ value: "Armenia", label: "Armenia" },
{ value: "Australia", label: "Australia" },
{ value: "Austria", label: "Austria" },
{ value: "Azerbaijan", label: "Azerbaijan" },
{ value: "Bahamas", label: "Bahamas" },
{ value: "Bahrain", label: "Bahrain" },
{ value: "Bangladesh", label: "Bangladesh" },
{ value: "Barbados", label: "Barbados" },
{ value: "Belarus", label: "Belarus" },
{ value: "Belgium", label: "Belgium" },
{ value: "Belize", label: "Belize" },
{ value: "Benin", label: "Benin" },
{ value: "Bhutan", label: "Bhutan" },
{ value: "Bolivia", label: "Bolivia" },
{ value: "Bosnia and Herzegovina", label: "Bosnia and Herzegovina" },
{ value: "Botswana", label: "Botswana" },
{ value: "Brazil", label: "Brazil" },
{ value: "Brunei", label: "Brunei" },
{ value: "Bulgaria", label: "Bulgaria" },
{ value: "Burkina Faso", label: "Burkina Faso" },
{ value: "Burundi", label: "Burundi" },
{ value: "Cabo Verde", label: "Cabo Verde" },
{ value: "Cambodia", label: "Cambodia" },
{ value: "Cameroon", label: "Cameroon" },
{ value: "Canada", label: "Canada" },
{ value: "Central African Republic", label: "Central African Republic" },
{ value: "Chad", label: "Chad" },
{ value: "Chile", label: "Chile" },
{ value: "China", label: "China" },
{ value: "Colombia", label: "Colombia" },
{ value: "Comoros", label: "Comoros" },
{ value: "Congo", label: "Congo" },
{ value: "Costa Rica", label: "Costa Rica" },
{ value: "Croatia", label: "Croatia" },
{ value: "Cuba", label: "Cuba" },
{ value: "Cyprus", label: "Cyprus" },
{ value: "Czech Republic", label: "Czech Republic" },
{
value: "Democratic Republic of the Congo",
label: "Democratic Republic of the Congo",
},
{ value: "Denmark", label: "Denmark" },
{ value: "Djibouti", label: "Djibouti" },
{ value: "Dominica", label: "Dominica" },
{ value: "Dominican Republic", label: "Dominican Republic" },
{ value: "East Timor", label: "East Timor" },
{ value: "Ecuador", label: "Ecuador" },
{ value: "Egypt", label: "Egypt" },
{ value: "El Salvador", label: "El Salvador" },
{ value: "Equatorial Guinea", label: "Equatorial Guinea" },
{ value: "Eritrea", label: "Eritrea" },
{ value: "Estonia", label: "Estonia" },
{ value: "Eswatini", label: "Eswatini" },
{ value: "Ethiopia", label: "Ethiopia" },
{ value: "Fiji", label: "Fiji" },
{ value: "Finland", label: "Finland" },
{ value: "France", label: "France" },
{ value: "Gabon", label: "Gabon" },
{ value: "Gambia", label: "Gambia" },
{ value: "Georgia", label: "Georgia" },
{ value: "Germany", label: "Germany" },
{ value: "Ghana", label: "Ghana" },
{ value: "Greece", label: "Greece" },
{ value: "Grenada", label: "Grenada" },
{ value: "Guatemala", label: "Guatemala" },
{ value: "Guinea", label: "Guinea" },
{ value: "Guinea-Bissau", label: "Guinea-Bissau" },
{ value: "Guyana", label: "Guyana" },
{ value: "Haiti", label: "Haiti" },
{ value: "Honduras", label: "Honduras" },
{ value: "Hungary", label: "Hungary" },
{ value: "Iceland", label: "Iceland" },
{ value: "India", label: "India" },
{ value: "Indonesia", label: "Indonesia" },
{ value: "Iran", label: "Iran" },
{ value: "Iraq", label: "Iraq" },
{ value: "Ireland", label: "Ireland" },
{ value: "Israel", label: "Israel" },
{ value: "Italy", label: "Italy" },
{ value: "Ivory Coast", label: "Ivory Coast" },
{ value: "Jamaica", label: "Jamaica" },
{ value: "Japan", label: "Japan" },
{ value: "Jordan", label: "Jordan" },
{ value: "Kazakhstan", label: "Kazakhstan" },
{ value: "Kenya", label: "Kenya" },
{ value: "Kiribati", label: "Kiribati" },
{ value: "Kuwait", label: "Kuwait" },
{ value: "Kyrgyzstan", label: "Kyrgyzstan" },
{ value: "Laos", label: "Laos" },
{ value: "Latvia", label: "Latvia" },
{ value: "Lebanon", label: "Lebanon" },
{ value: "Lesotho", label: "Lesotho" },
{ value: "Liberia", label: "Liberia" },
{ value: "Libya", label: "Libya" },
{ value: "Liechtenstein", label: "Liechtenstein" },
{ value: "Lithuania", label: "Lithuania" },
{ value: "Luxembourg", label: "Luxembourg" },
{ value: "Madagascar", label: "Madagascar" },
{ value: "Malawi", label: "Malawi" },
{ value: "Malaysia", label: "Malaysia" },
{ value: "Maldives", label: "Maldives" },
{ value: "Mali", label: "Mali" },
{ value: "Malta", label: "Malta" },
{ value: "Marshall Islands", label: "Marshall Islands" },
{ value: "Mauritania", label: "Mauritania" },
{ value: "Mauritius", label: "Mauritius" },
{ value: "Mexico", label: "Mexico" },
{ value: "Micronesia", label: "Micronesia" },
{ value: "Moldova", label: "Moldova" },
{ value: "Monaco", label: "Monaco" },
{ value: "Mongolia", label: "Mongolia" },
{ value: "Montenegro", label: "Montenegro" },
{ value: "Morocco", label: "Morocco" },
{ value: "Mozambique", label: "Mozambique" },
{ value: "Myanmar", label: "Myanmar" },
{ value: "Namibia", label: "Namibia" },
{ value: "Nauru", label: "Nauru" },
{ value: "Nepal", label: "Nepal" },
{ value: "Netherlands", label: "Netherlands" },
{ value: "New Zealand", label: "New Zealand" },
{ value: "Nicaragua", label: "Nicaragua" },
{ value: "Niger", label: "Niger" },
{ value: "Nigeria", label: "Nigeria" },
{ value: "North Korea", label: "North Korea" },
{ value: "North Macedonia", label: "North Macedonia" },
{ value: "Norway", label: "Norway" },
{ value: "Oman", label: "Oman" },
{ value: "Pakistan", label: "Pakistan" },
{ value: "Palau", label: "Palau" },
{ value: "Palestine", label: "Palestine" },
{ value: "Panama", label: "Panama" },
{ value: "Papua New Guinea", label: "Papua New Guinea" },
{ value: "Paraguay", label: "Paraguay" },
{ value: "Peru", label: "Peru" },
{ value: "Philippines", label: "Philippines" },
{ value: "Poland", label: "Poland" },
{ value: "Portugal", label: "Portugal" },
{ value: "Qatar", label: "Qatar" },
{ value: "Romania", label: "Romania" },
{ value: "Russia", label: "Russia" },
{ value: "Rwanda", label: "Rwanda" },
{ value: "Saint Kitts and Nevis", label: "Saint Kitts and Nevis" },
{ value: "Saint Lucia", label: "Saint Lucia" },
{
value: "Saint Vincent and the Grenadines",
label: "Saint Vincent and the Grenadines",
},
{ value: "Samoa", label: "Samoa" },
{ value: "San Marino", label: "San Marino" },
{ value: "Sao Tome and Principe", label: "Sao Tome and Principe" },
{ value: "Saudi Arabia", label: "Saudi Arabia" },
{ value: "Senegal", label: "Senegal" },
{ value: "Serbia", label: "Serbia" },
{ value: "Seychelles", label: "Seychelles" },
{ value: "Sierra Leone", label: "Sierra Leone" },
{ value: "Singapore", label: "Singapore" },
{ value: "Slovakia", label: "Slovakia" },
{ value: "Slovenia", label: "Slovenia" },
{ value: "Solomon Islands", label: "Solomon Islands" },
{ value: "Somalia", label: "Somalia" },
{ value: "South Africa", label: "South Africa" },
{ value: "South Korea", label: "South Korea" },
{ value: "South Sudan", label: "South Sudan" },
{ value: "Spain", label: "Spain" },
{ value: "Sri Lanka", label: "Sri Lanka" },
{ value: "Sudan", label: "Sudan" },
{ value: "Suriname", label: "Suriname" },
{ value: "Sweden", label: "Sweden" },
{ value: "Switzerland", label: "Switzerland" },
{ value: "Syria", label: "Syria" },
{ value: "Taiwan", label: "Taiwan" },
{ value: "Tajikistan", label: "Tajikistan" },
{ value: "Tanzania", label: "Tanzania" },
{ value: "Thailand", label: "Thailand" },
{ value: "Togo", label: "Togo" },
{ value: "Tonga", label: "Tonga" },
{ value: "Trinidad and Tobago", label: "Trinidad and Tobago" },
{ value: "Tunisia", label: "Tunisia" },
{ value: "Turkey", label: "Turkey" },
{ value: "Turkmenistan", label: "Turkmenistan" },
{ value: "Tuvalu", label: "Tuvalu" },
{ value: "Uganda", label: "Uganda" },
{ value: "Ukraine", label: "Ukraine" },
{ value: "United Arab Emirates", label: "United Arab Emirates" },
{ value: "United Kingdom", label: "United Kingdom" },
{ value: "United States", label: "United States" },
{ value: "Uruguay", label: "Uruguay" },
{ value: "Uzbekistan", label: "Uzbekistan" },
{ value: "Vanuatu", label: "Vanuatu" },
{ value: "Vatican City", label: "Vatican City" },
{ value: "Venezuela", label: "Venezuela" },
{ value: "Vietnam", label: "Vietnam" },
{ value: "Yemen", label: "Yemen" },
{ value: "Zambia", label: "Zambia" },
{ value: "Zimbabwe", label: "Zimbabwe" },
];
// Phone number formatting
export function formatPhoneNumber(value: string): string {
// Remove all non-numeric characters
const phoneNumber = value.replace(/\D/g, "");
// Format as US phone number
if (phoneNumber.length <= 3) {
return phoneNumber;
} else if (phoneNumber.length <= 6) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
} else if (phoneNumber.length <= 10) {
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
} else {
// Handle international numbers
return `+${phoneNumber.slice(0, phoneNumber.length - 10)} (${phoneNumber.slice(-10, -7)}) ${phoneNumber.slice(-7, -4)}-${phoneNumber.slice(-4)}`;
}
}
// Email validation
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// URL formatting
export function formatWebsiteUrl(url: string): string {
if (!url) return "";
// If URL doesn't start with http:// or https://, add https://
if (!/^https?:\/\//i.exec(url)) {
return `https://${url}`;
}
return url;
}
// Postal code formatting
export function formatPostalCode(
value: string,
country = "United States",
): string {
if (country === "United States") {
// Format as US ZIP code (12345 or 12345-6789)
const digits = value.replace(/\D/g, "");
if (digits.length <= 5) {
return digits;
} else {
return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`;
}
} else if (country === "Canada") {
// Format as Canadian postal code (A1A 1A1)
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
if (cleaned.length <= 3) {
return cleaned;
} else {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`;
}
}
// Return as-is for other countries
return value;
}
// Tax ID formatting
export function formatTaxId(value: string, type = "EIN"): string {
const digits = value.replace(/\D/g, "");
if (type === "EIN") {
// Format as XX-XXXXXXX
if (digits.length <= 2) {
return digits;
} else {
return `${digits.slice(0, 2)}-${digits.slice(2, 9)}`;
}
} else if (type === "SSN") {
// Format as XXX-XX-XXXX
if (digits.length <= 3) {
return digits;
} else if (digits.length <= 5) {
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
} else {
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 9)}`;
}
}
return value;
}
// Form validation messages
export const VALIDATION_MESSAGES = {
required: "This field is required",
email: "Please enter a valid email address",
phone: "Please enter a valid phone number",
url: "Please enter a valid URL",
postalCode: "Please enter a valid postal code",
taxId: "Please enter a valid tax ID",
};
// Form field placeholders
export const PLACEHOLDERS = {
name: "Enter name",
email: "email@example.com",
phone: "(555) 123-4567",
addressLine1: "123 Main Street",
addressLine2: "Suite 100",
city: "San Francisco",
postalCode: "12345",
website: "www.example.com",
taxId: "12-3456789",
};
+7
View File
@@ -0,0 +1,7 @@
import { createHash } from "crypto";
export function getGravatarUrl(email: string, size = 200) {
const trimmedEmail = email.trim().toLowerCase();
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
}
@@ -0,0 +1,68 @@
export const CSV_TEMPLATE_FILENAME = "acme-january-template.csv";
export const JSON_TEMPLATE_FILENAME = "invoice-import-template.json";
/** Matches parseInvoiceCSV column expectations (date, item/description, quantity, rate). */
export const CSV_TEMPLATE = `date,item,description,quantity,rate
2024-01-15,,API development,8,125.00
2024-01-16,Design,Design review and feedback,2,125.00
1/17/24,,Documentation,4,125.00`;
/** Matches parseInvoiceJSON shape (client, issueDate, dueDate, items). */
export const JSON_TEMPLATE = JSON.stringify(
{
invoices: [
{
name: "January Services",
issueDate: "2024-01-31",
dueDate: "2024-03-01",
client: {
name: "Acme Corp",
email: "billing@acme.com",
},
items: [
{
date: "2024-01-15",
description: "API development",
quantity: 8,
rate: 125,
},
{
date: "2024-01-16",
item: "Design",
description: "Design review",
quantity: 2,
rate: 125,
},
],
},
],
},
null,
2,
);
export function downloadImportTemplate(
content: string,
filename: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function downloadCsvTemplate() {
downloadImportTemplate(CSV_TEMPLATE, CSV_TEMPLATE_FILENAME, "text/csv");
}
export function downloadJsonTemplate() {
downloadImportTemplate(
JSON_TEMPLATE,
JSON_TEMPLATE_FILENAME,
"application/json",
);
}
+369
View File
@@ -0,0 +1,369 @@
export type ImportFormat = "csv" | "json";
export interface ImportItem {
date?: Date;
description: string;
quantity: number;
rate: number;
}
export interface ImportClientRef {
name?: string;
email?: string;
}
export interface ImportInvoice {
name: string;
issueDate?: Date;
dueDate?: Date;
client?: ImportClientRef;
clientId?: string;
items: ImportItem[];
sourceFile?: string;
errors: string[];
}
const COLUMN_ALIASES: Record<string, string[]> = {
date: ["date", "item date", "work date", "service date"],
item: ["item", "title", "name", "task"],
description: ["description", "desc", "details", "work", "notes"],
quantity: ["quantity", "qty", "hours", "hour", "units", "amount hours"],
rate: ["rate", "hourly rate", "price", "unit price", "unit_rate"],
};
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replace(/[_-]+/g, " ");
}
function resolveColumnIndex(
headers: string[],
field: keyof typeof COLUMN_ALIASES,
): number {
const aliases = COLUMN_ALIASES[field] ?? [];
for (let i = 0; i < headers.length; i++) {
const normalized = normalizeHeader(headers[i] ?? "");
if (aliases.includes(normalized)) return i;
}
return -1;
}
export function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"';
i += 2;
} else {
inQuotes = !inQuotes;
i++;
}
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
i++;
} else {
current += char;
i++;
}
}
result.push(current.trim());
return result;
}
export function parseFlexibleDate(dateStr: string): Date | undefined {
const trimmed = dateStr.trim();
if (!trimmed) return undefined;
// ISO date (YYYY-MM-DD)
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
if (isoMatch) {
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
}
// M/DD/YY or M/DD/YYYY
const slashParts = trimmed.split("/");
if (slashParts.length === 3) {
const month = parseInt(slashParts[0] ?? "1", 10) - 1;
const day = parseInt(slashParts[1] ?? "1", 10);
let year = parseInt(slashParts[2] ?? "2000", 10);
if (year < 100) year += 2000;
const d = new Date(year, month, day);
if (!isNaN(d.getTime())) return d;
}
const d = new Date(trimmed);
if (!isNaN(d.getTime())) return d;
return undefined;
}
function parseNumber(value: string): number {
const cleaned = value.replace(/[$,\s]/g, "");
const n = parseFloat(cleaned);
return isNaN(n) ? 0 : n;
}
function stripExtension(filename: string): string {
return filename.replace(/\.[^.]+$/, "");
}
function buildItemDescription(item: string, description: string): string {
const parts = [item.trim(), description.trim()].filter(Boolean);
return parts.join(" — ") || "Imported item";
}
function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
const itemDates = items
.map((i) => i.date)
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
if (itemDates.length > 0) {
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
}
return fallback ?? new Date();
}
function defaultDueDate(issueDate: Date): Date {
const due = new Date(issueDate);
due.setDate(due.getDate() + 30);
return due;
}
export function parseInvoiceCSV(
csvText: string,
filename: string,
): ImportInvoice {
const errors: string[] = [];
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
if (lines.length === 0) {
return {
name: stripExtension(filename),
items: [],
sourceFile: filename,
errors: ["File is empty"],
};
}
const headers = parseCSVLine(lines[0] ?? "");
const dateIdx = resolveColumnIndex(headers, "date");
const itemIdx = resolveColumnIndex(headers, "item");
const descIdx = resolveColumnIndex(headers, "description");
const qtyIdx = resolveColumnIndex(headers, "quantity");
const rateIdx = resolveColumnIndex(headers, "rate");
if (descIdx === -1 && itemIdx === -1) {
errors.push(
'Missing description column (expected "description" or "item")',
);
}
if (qtyIdx === -1) {
errors.push('Missing quantity column (expected "quantity" or "hours")');
}
if (rateIdx === -1) {
errors.push('Missing rate column (expected "rate" or "price")');
}
const items: ImportItem[] = [];
for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) {
const values = parseCSVLine(lines[rowIdx] ?? "");
if (values.every((v) => !v.trim())) continue;
const itemText = itemIdx >= 0 ? (values[itemIdx] ?? "") : "";
const descText = descIdx >= 0 ? (values[descIdx] ?? "") : "";
const description = buildItemDescription(itemText, descText);
const quantity = qtyIdx >= 0 ? parseNumber(values[qtyIdx] ?? "0") : 0;
const rate = rateIdx >= 0 ? parseNumber(values[rateIdx] ?? "0") : 0;
if (!description || description === "Imported item") {
if (!itemText && !descText) continue;
}
if (quantity <= 0) {
errors.push(`Row ${rowIdx + 1}: quantity must be greater than 0`);
continue;
}
if (rate <= 0) {
errors.push(`Row ${rowIdx + 1}: rate must be greater than 0`);
continue;
}
let date: Date | undefined;
if (dateIdx >= 0) {
const rawDate = values[dateIdx] ?? "";
if (rawDate.trim()) {
date = parseFlexibleDate(rawDate);
if (!date) {
errors.push(`Row ${rowIdx + 1}: invalid date "${rawDate}"`);
}
}
}
items.push({ date, description, quantity, rate });
}
const issueDate = deriveIssueDate(items);
return {
name: stripExtension(filename),
issueDate,
dueDate: defaultDueDate(issueDate),
items,
sourceFile: filename,
errors:
items.length === 0 && errors.length === 0
? ["No valid line items found"]
: errors,
};
}
interface JsonInvoiceItem {
date?: string;
description?: string;
item?: string;
quantity?: number;
hours?: number;
rate?: number;
}
interface JsonInvoice {
name?: string;
invoiceNumber?: string;
issueDate?: string;
dueDate?: string;
client?: { name?: string; email?: string };
clientName?: string;
items?: JsonInvoiceItem[];
}
function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
const errors: string[] = [];
const name = raw.name ?? raw.invoiceNumber ?? `Imported Invoice ${index + 1}`;
const clientName = raw.client?.name ?? raw.clientName;
const clientEmail = raw.client?.email;
const items: ImportItem[] = (raw.items ?? []).map((item, itemIdx) => {
const description = buildItemDescription(
item.item ?? "",
item.description ?? "",
);
const quantity = item.quantity ?? item.hours ?? 0;
const rate = item.rate ?? 0;
if (!description || description === "Imported item") {
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
}
if (quantity <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: quantity must be greater than 0`,
);
}
if (rate <= 0) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: rate must be greater than 0`,
);
}
let date: Date | undefined;
if (item.date) {
date = parseFlexibleDate(item.date);
if (!date) {
errors.push(
`Invoice "${name}" item ${itemIdx + 1}: invalid date "${item.date}"`,
);
}
}
return { date, description, quantity, rate };
});
let issueDate: Date | undefined;
if (raw.issueDate) {
issueDate = parseFlexibleDate(raw.issueDate);
if (!issueDate) {
errors.push(`Invoice "${name}": invalid issue date "${raw.issueDate}"`);
}
}
let dueDate: Date | undefined;
if (raw.dueDate) {
dueDate = parseFlexibleDate(raw.dueDate);
if (!dueDate) {
errors.push(`Invoice "${name}": invalid due date "${raw.dueDate}"`);
}
}
const resolvedIssue = issueDate ?? deriveIssueDate(items);
const resolvedDue = dueDate ?? defaultDueDate(resolvedIssue);
if (items.length === 0) {
errors.push(`Invoice "${name}": at least one item is required`);
}
return {
name,
issueDate: resolvedIssue,
dueDate: resolvedDue,
client:
clientName || clientEmail
? { name: clientName, email: clientEmail }
: undefined,
items,
errors,
};
}
export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [
{
name: "JSON Import",
items: [],
errors: ["Invalid JSON format"],
},
];
}
let rawInvoices: JsonInvoice[] = [];
if (Array.isArray(parsed)) {
rawInvoices = parsed as JsonInvoice[];
} else if (parsed && typeof parsed === "object") {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj.invoices)) {
rawInvoices = obj.invoices as JsonInvoice[];
} else if (obj.items || obj.name || obj.invoiceNumber) {
rawInvoices = [obj];
}
}
if (rawInvoices.length === 0) {
return [
{
name: "JSON Import",
items: [],
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
},
];
}
return rawInvoices.map((inv, idx) => normalizeJsonInvoice(inv, idx));
}
export function detectImportFormat(filename: string): ImportFormat {
return filename.toLowerCase().endsWith(".json") ? "json" : "csv";
}
+38
View File
@@ -0,0 +1,38 @@
export type LineItemBillingType = "hourly" | "fixed";
export function isFixedLineItem(hours: number): boolean {
return hours === 0;
}
export function getLineItemBillingType(hours: number): LineItemBillingType {
return isFixedLineItem(hours) ? "fixed" : "hourly";
}
export function calculateLineItemAmount(hours: number, rate: number): number {
return isFixedLineItem(hours) ? rate : hours * rate;
}
export function formatLineItemDetail(
hours: number,
rate: number,
formatCurrency: (amount: number) => string,
): string {
if (isFixedLineItem(hours)) {
return "Fixed amount";
}
return `${hours}h @ ${formatCurrency(rate)}/hr`;
}
export function applyBillingTypeChange(
billingType: LineItemBillingType,
current: { hours: number; rate: number },
): { hours: number; rate: number; amount: number } {
if (billingType === "fixed") {
const amount = calculateLineItemAmount(current.hours, current.rate);
return { hours: 0, rate: amount, amount };
}
const hours = current.hours > 0 ? current.hours : 1;
const amount = calculateLineItemAmount(hours, current.rate);
return { hours, rate: current.rate, amount };
}
+137
View File
@@ -0,0 +1,137 @@
import type {
StoredInvoiceStatus,
EffectiveInvoiceStatus,
} 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;
}
/**
* Check if an invoice is overdue
*/
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
}
/**
* 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);
}
/**
* Status configuration for UI display
*/
export const statusConfig = {
draft: {
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
description: "Invoice is being prepared",
},
sent: {
label: "Sent",
color: "bg-primary/10 text-primary",
description: "Invoice sent to client",
},
paid: {
label: "Paid",
color: "bg-primary/10 text-primary",
description: "Payment received",
},
overdue: {
label: "Overdue",
color: "bg-destructive/10 text-destructive",
description: "Payment is overdue",
},
} as const;
/**
* Get status configuration for display
*/
export function getStatusConfig(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
) {
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
return statusConfig[effectiveStatus];
}
/**
* 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 [];
}
}
/**
* Check if a status transition is valid
*/
export function isValidStatusTransition(
from: StoredInvoiceStatus,
to: StoredInvoiceStatus,
): boolean {
const validTransitions = getValidStatusTransitions(from);
return validTransitions.includes(to);
}
+8
View File
@@ -0,0 +1,8 @@
import { getAppUrl } from "~/lib/app-url";
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
export const LEGAL_LAST_UPDATED = "June 18, 2026";
export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
export const LEGAL_WEBSITE = getAppUrl();
+79
View File
@@ -0,0 +1,79 @@
import {
Settings,
LayoutDashboard,
Users,
FileText,
Receipt,
BarChart2,
Shield,
RefreshCw,
Clock,
} from "lucide-react";
export interface NavLink {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export interface NavSection {
title: string;
links: NavLink[];
}
export function isNavLinkActive(pathname: string, href: string): boolean {
if (href === "/dashboard/entities") {
return (
pathname === href ||
pathname.startsWith("/dashboard/clients") ||
pathname.startsWith("/dashboard/businesses")
);
}
if (href === "/dashboard/time-clock") {
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
}
return pathname === href;
}
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
return navigationConfig
.map((section) => ({
...section,
links: section.links.filter(
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
),
}))
.filter((section) => section.links.length > 0);
}
export const navigationConfig: NavSection[] = [
{
title: "Main",
links: [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Entities", href: "/dashboard/entities", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
{
name: "Recurring",
href: "/dashboard/invoices/recurring",
icon: RefreshCw,
},
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
],
},
{
title: "Account",
links: [
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
{
name: "Administration",
href: "/dashboard/administration",
icon: Shield,
},
],
},
];
+173
View File
@@ -0,0 +1,173 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path";
import type * as S3ClientModule from "@aws-sdk/client-s3";
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
function isS3Configured(): boolean {
return Boolean(
process.env.S3_BUCKET &&
process.env.S3_ACCESS_KEY &&
process.env.S3_SECRET_KEY,
);
}
export function getStorageBackend(): "s3" | "local" {
return isS3Configured() ? "s3" : "local";
}
type S3Module = typeof S3ClientModule;
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
let s3DnsHintLogged = false;
let s3BareGarageHintLogged = false;
function shouldForcePathStyle(): boolean {
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
if (override === "true" || override === "1") return true;
if (override === "false" || override === "0") return false;
return Boolean(process.env.S3_ENDPOINT);
}
function logBareGarageEndpointHint(): void {
if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return;
const endpoint = process.env.S3_ENDPOINT;
if (!endpoint) return;
try {
const { hostname } = new URL(endpoint);
if (hostname !== "garage") return;
s3BareGarageHintLogged = true;
console.warn(
"[object-storage] S3_ENDPOINT hostname is bare 'garage'. " +
"That only resolves inside a single Docker Compose stack. " +
"Coolify Application + separate Garage compose: set S3_ENDPOINT to " +
"SERVICE_URL_GARAGE_3900 (public domain) or http://garage-<resource-uuid>:3900. " +
"See docs/COOLIFY.md.",
);
} catch {
// Invalid URL — env validation or S3 client will surface it.
}
}
function logS3DnsHint(error: unknown): void {
if (s3DnsHintLogged) return;
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
s3DnsHintLogged = true;
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
console.error(
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
"Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
);
}
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
logS3DnsHint(error);
throw error;
}
}
async function getS3() {
s3ModulePromise ??= import("@aws-sdk/client-s3");
const mod = await s3ModulePromise;
if (!s3Client) {
logBareGarageEndpointHint();
s3Client = new mod.S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
forcePathStyle: shouldForcePathStyle(),
});
}
return { client: s3Client, ...mod };
}
function localPathForKey(key: string) {
return path.join(LOCAL_RECEIPTS_DIR, key);
}
export async function putObject(
key: string,
body: Buffer,
contentType: string,
): Promise<void> {
if (isS3Configured()) {
const { client, PutObjectCommand } = await getS3();
await withS3Diagnostics(() =>
client.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
}),
),
);
return;
}
const filePath = localPathForKey(key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, body);
}
export async function getObject(key: string): Promise<Buffer> {
if (isS3Configured()) {
const { client, GetObjectCommand } = await getS3();
const response = await withS3Diagnostics(() =>
client.send(
new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) {
throw new Error("Empty object body");
}
return Buffer.from(bytes);
}
return readFile(localPathForKey(key));
}
export async function deleteObject(key: string): Promise<void> {
if (isS3Configured()) {
const { client, DeleteObjectCommand } = await getS3();
await withS3Diagnostics(() =>
client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}),
),
);
return;
}
try {
await unlink(localPathForKey(key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
export function isAllowedReceiptMime(mimeType: string): boolean {
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
return normalized === "application/pdf" || normalized.startsWith("image/");
}
+30
View File
@@ -0,0 +1,30 @@
export interface ParsedLineItem {
description: string;
hours: number | null;
rate: number | null;
}
export function parseLineItem(input: string): ParsedLineItem {
let text = input.trim();
let hours: number | null = null;
let rate: number | null = null;
// Extract hours: "3h", "3hr", "3hrs", "3 hours", "3.5hours"
const hoursMatch = /(\d+\.?\d*)\s*h(?:ours?|rs?)\b/i.exec(text);
if (hoursMatch?.[0] && hoursMatch[1]) {
hours = parseFloat(hoursMatch[1]);
text = text.replace(hoursMatch[0], " ").trim();
}
// Extract rate: "@120", "@$120", "at 120", "at $120", "$120/hr", "$120ph"
const rateMatch = /(?:@\s*\$?|at\s+\$?)(\d+\.?\d*)|(\$\d+\.?\d*)(?:\/h(?:rs?)?|ph)?\b/i.exec(text);
if (rateMatch?.[0]) {
const rawRate = rateMatch[1] ?? rateMatch[2] ?? "";
rate = parseFloat(rawRate.replace("$", ""));
text = text.replace(rateMatch[0], " ").trim();
}
const description = text.replace(/\s+/g, " ").replace(/^[\s,]+|[\s,]+$/g, "").trim();
return { description: description || input.trim(), hours, rate };
}
+85
View File
@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { Resend } from "resend";
import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import {
createPasswordResetToken,
hashPasswordResetToken,
} from "~/lib/reset-token";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export type PasswordResetResult = {
success: boolean;
emailSent: boolean;
userEmail?: string;
};
export async function sendPasswordResetEmail(input: {
userEmail: string;
userName?: string;
resetToken: string;
}): Promise<PasswordResetResult> {
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: input.userEmail,
userName: input.userName,
resetToken: input.resetToken,
resetUrl,
expiryHours: 1,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: input.userEmail,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: input.userEmail };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: input.userEmail };
}
}
export async function sendPasswordResetForUser(
userId: string,
): Promise<PasswordResetResult> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, name: true },
});
if (!user) {
return { success: false, emailSent: false };
}
const resetToken = createPasswordResetToken();
const resetTokenHash = hashPasswordResetToken(resetToken);
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
await db
.update(users)
.set({ resetToken: resetTokenHash, resetTokenExpiry })
.where(eq(users.id, user.id));
return sendPasswordResetEmail({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
});
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
export interface ResolvedPdfFonts {
regular: string;
bold: string;
mono: string;
monoBold: string;
}
export const pdfFontFamilyOptions: {
value: PdfFontFamily;
label: string;
description: string;
}[] = [
{
value: "sans",
label: "Modern",
description: "Clean sans-serif (Helvetica).",
},
{
value: "serif",
label: "Classic",
description: "Traditional serif (Times).",
},
{
value: "mono",
label: "Monospace",
description: "Fixed-width type (Courier).",
},
];
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
switch (family) {
case "serif":
return {
regular: "Times-Roman",
bold: "Times-Bold",
};
case "mono":
return {
regular: "Courier",
bold: "Courier-Bold",
};
case "sans":
default:
return {
regular: "Helvetica",
bold: "Helvetica-Bold",
};
}
}
function resolveNumericFonts(
family: PdfFontFamily,
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
switch (family) {
case "serif":
return {
mono: "Times-Roman",
monoBold: "Times-Bold",
};
case "mono":
return {
mono: "Courier",
monoBold: "Courier-Bold",
};
case "sans":
default:
return {
mono: "Helvetica",
monoBold: "Helvetica-Bold",
};
}
}
export function resolvePdfFonts(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily = "mono",
): ResolvedPdfFonts {
return {
...resolveBodyFonts(bodyFamily),
...resolveNumericFonts(numericFamily),
};
}
export function pdfFontCacheKey(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): string {
return `${bodyFamily}:${numericFamily}`;
}
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
return pdfFontFamilySchema.safeParse(value).success;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Pluralization rules for common entities in the app
*/
const PLURALIZATION_RULES: Record<
string,
{ singular: string; plural: string }
> = {
business: { singular: "Business", plural: "Businesses" },
client: { singular: "Client", plural: "Clients" },
invoice: { singular: "Invoice", plural: "Invoices" },
setting: { singular: "Setting", plural: "Settings" },
user: { singular: "User", plural: "Users" },
payment: { singular: "Payment", plural: "Payments" },
item: { singular: "Item", plural: "Items" },
tax: { singular: "Tax", plural: "Taxes" },
category: { singular: "Category", plural: "Categories" },
company: { singular: "Company", plural: "Companies" },
entity: { singular: "Entity", plural: "Entities" },
expense: { singular: "Expense", plural: "Expenses" },
report: { singular: "Report", plural: "Reports" },
};
/**
* Get the plural form of a word
*/
export function pluralize(word: string, count?: number): string {
// If count is provided and is 1, return singular
if (count === 1) {
return word;
}
const lowerWord = word.toLowerCase();
// Check if we have a specific rule for this word
if (PLURALIZATION_RULES[lowerWord]) {
return PLURALIZATION_RULES[lowerWord].plural;
}
// Apply general pluralization rules
// Words ending in s, ss, sh, ch, x, z
if (/(?:s|ss|sh|ch|x|z)$/i.test(word)) {
return word + "es";
}
// Words ending in consonant + y
if (/[^aeiou]y$/i.test(word)) {
return word.slice(0, -1) + "ies";
}
// Words ending in f or fe
if (/(?:f|fe)$/i.test(word)) {
return word.replace(/(?:f|fe)$/i, "ves");
}
// Default: just add 's'
return word + "s";
}
/**
* Get the singular form of a word
*/
export function singularize(word: string): string {
const lowerWord = word.toLowerCase();
// Check if we have a specific rule for this word (search by plural)
const rule = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lowerWord,
);
if (rule) {
return rule.singular;
}
// Apply general singularization rules
// Words ending in ies
if (/ies$/i.test(word)) {
return word.slice(0, -3) + "y";
}
// Words ending in es
if (/(?:s|ss|sh|ch|x|z)es$/i.test(word)) {
return word.slice(0, -2);
}
// Words ending in ves
if (/ves$/i.test(word)) {
return word.slice(0, -3) + "f";
}
// Words ending in s
if (/s$/i.test(word) && word.length > 1) {
return word.slice(0, -1);
}
// Default: return as is
return word;
}
/**
* Capitalize the first letter of a word
*/
export function capitalize(word: string): string {
if (!word) return word;
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
/**
* Get a properly formatted label for a route segment
*/
export function getRouteLabel(segment: string, isPlural = true): string {
const lower = segment.toLowerCase();
// Route segments are often already plural (e.g. "entities", "invoices")
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lower,
);
if (ruleByPlural) {
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
}
const rule = PLURALIZATION_RULES[lower];
if (rule) {
return isPlural ? rule.plural : rule.singular;
}
const singularForm = singularize(segment);
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
if (singularRule) {
return isPlural ? singularRule.plural : singularRule.singular;
}
const capitalized = capitalize(segment);
if (isPlural && /s$/i.test(segment)) {
return capitalized;
}
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
}
+21
View File
@@ -0,0 +1,21 @@
/** Routes that do not require authentication or server session lookups. */
export const PUBLIC_ROUTES = [
"/",
"/auth/signin",
"/auth/register",
"/auth/forgot-password",
"/auth/reset-password",
"/privacy",
"/terms",
] as const;
/** Path prefixes treated as public (e.g. shareable invoice links). */
export const PUBLIC_ROUTE_PREFIXES = ["/i/"] as const;
export function isPublicRoute(pathname: string): boolean {
if ((PUBLIC_ROUTES as readonly string[]).includes(pathname)) {
return true;
}
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
type RateLimitRule = {
windowMs: number;
max: number;
};
type RateLimitRecord = {
count: number;
resetAt: number;
};
const buckets = new Map<string, RateLimitRecord>();
function clientIp(request: NextRequest) {
return (
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-real-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
export function hashRateLimitPart(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export function rateLimitKey(request: NextRequest, scope: string, subject?: string) {
const parts = [scope, clientIp(request)];
if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim()));
return parts.join(":");
}
function retryAfterSeconds(resetAt: number) {
return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
}
export function checkRateLimit(key: string, rule: RateLimitRule) {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
return { allowed: true, retryAfter: 0 };
}
existing.count += 1;
if (existing.count <= rule.max) {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) };
}
export function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: "Too many attempts. Please wait and try again." },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Retry-After": String(retryAfter),
},
},
);
}
export function requireRateLimit(key: string, rule: RateLimitRule) {
const result = checkRateLimit(key, rule);
return result.allowed ? null : rateLimitResponse(result.retryAfter);
}
+63
View File
@@ -0,0 +1,63 @@
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,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function createPasswordResetToken() {
return randomBytes(32).toString("hex");
}
export function hashPasswordResetToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
const FALLBACK_CALLBACK_PATH = "/dashboard";
export function safeCallbackPath(value: string | null | undefined) {
if (!value) return FALLBACK_CALLBACK_PATH;
const trimmed = value.trim();
if (
!trimmed.startsWith("/") ||
trimmed.startsWith("//") ||
trimmed.includes("\\") ||
/[\u0000-\u001f\u007f]/.test(trimmed)
) {
return FALLBACK_CALLBACK_PATH;
}
try {
const url = new URL(trimmed, "https://beenvoice.local");
if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return FALLBACK_CALLBACK_PATH;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { and, eq, ne } from "drizzle-orm";
import { db } from "~/server/db";
import { sessions } from "~/server/db/schema";
export async function revokeUserSessions(userId: string, exceptToken?: string | null) {
const condition = exceptToken
? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken))
: eq(sessions.userId, userId);
await db.delete(sessions).where(condition);
}
+38
View File
@@ -0,0 +1,38 @@
import "server-only";
/**
* Lightweight defense-in-depth pass over uploaded SVG markup before it is
* stored. Strips executable content (scripts, event handlers, external
* references) so a malicious SVG can't run script if it's ever rendered
* inline (dangerouslySetInnerHTML) rather than via <img src>. Not a full
* parser — good enough for a self-uploaded logo, not a substitute for
* treating SVG as active content from an untrusted source.
*/
export function sanitizeSvg(input: string): string {
let svg = input;
// Strip <script>...</script> blocks and self-closing <script/> tags.
svg = svg.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
svg = svg.replace(/<script\b[^>]*\/>/gi, "");
// Strip on* event handler attributes (onload, onclick, onerror, ...).
svg = svg.replace(/\son\w+\s*=\s*"[^"]*"/gi, "");
svg = svg.replace(/\son\w+\s*=\s*'[^']*'/gi, "");
svg = svg.replace(/\son\w+\s*=\s*[^\s>]+/gi, "");
// Strip javascript: URIs in href/xlink:href/src attributes.
svg = svg.replace(
/((?:xlink:href|href|src)\s*=\s*)"javascript:[^"]*"/gi,
'$1""',
);
svg = svg.replace(
/((?:xlink:href|href|src)\s*=\s*)'javascript:[^']*'/gi,
"$1''",
);
// Strip <foreignObject> (can embed arbitrary HTML) and <iframe>.
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
svg = svg.replace(/<iframe[\s\S]*?<\/iframe\s*>/gi, "");
return svg;
}
+11
View File
@@ -0,0 +1,11 @@
const STORAGE_KEY = "beenvoice:time-clock:last-client";
export function getLastTimeClockClientId(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_KEY);
}
export function setLastTimeClockClientId(clientId: string): void {
if (!clientId || typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, clientId);
}
+87
View File
@@ -0,0 +1,87 @@
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function normalizeOptionalId(value?: string | null): string | null {
const trimmed = value?.trim();
return trimmed == null || trimmed === "" ? null : trimmed;
}
export function resolveEffectiveHourlyRate(
enteredRate: number,
client?: { defaultHourlyRate?: number | null } | null,
): number {
if (Number.isFinite(enteredRate) && enteredRate > 0) return enteredRate;
const clientRate = client?.defaultHourlyRate ?? 0;
if (Number.isFinite(clientRate) && clientRate > 0) return clientRate;
return 0;
}
export function startedAtFromMinutesAgo(minutes: number): Date {
return new Date(Date.now() - minutes * 60 * 1000);
}
export function resolveClockDescription(
title: string,
existingDescription?: string | null,
): string {
const trimmed = title.trim();
if (trimmed) return trimmed;
if (existingDescription?.trim()) return existingDescription.trim();
return "";
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
return trimmed;
}
export function resolveBillingDescription(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
}
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;
rate: number;
invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
}): string {
const amount = input.hours * input.rate;
switch (input.outcome) {
case "linked_to_invoice":
if (input.invoice) {
const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to invoice ${label}`;
}
return `Added ${input.hours}h to invoice`;
case "saved_no_invoice":
return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`;
case "saved_no_client":
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
case "zero_hours":
return "Timer stopped (less than minimum billable increment).";
}
}
+64
View File
@@ -0,0 +1,64 @@
export function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
export function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
export type TimeEntryListItem = {
id: string;
description: string | null;
hours: number | null;
rate: number | null;
startedAt: Date;
endedAt: Date | null;
clientId: string | null;
invoiceId: string | null;
client?: { id: string; name: string } | null;
invoice?: {
id: string;
invoiceNumber: string;
invoicePrefix: string | null;
} | null;
};
export function groupEntriesByDate<T extends { startedAt: Date }>(
entries: T[],
): { dateKey: string; label: string; entries: T[] }[] {
const groups = new Map<string, T[]>();
for (const entry of entries) {
const d = new Date(entry.startedAt);
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const existing = groups.get(dateKey);
if (existing) {
existing.push(entry);
} else {
groups.set(dateKey, [entry]);
}
}
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
const sample = new Date(groupEntries[0]!.startedAt);
const label = sample.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
return { dateKey, label, entries: groupEntries };
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}