add receipts support

This commit is contained in:
2026-06-26 23:07:43 -04:00
parent 85df7c4627
commit b9a9b813d2
55 changed files with 3239 additions and 441 deletions
+8
View File
@@ -1,4 +1,8 @@
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;
@@ -6,6 +10,8 @@ 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>;
@@ -14,6 +20,8 @@ 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,
+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,
});
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { env } from "~/env";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
export {
colorModeSchema,
defaultColorMode,
defaultPdfSettings,
pdfFontFamilySchema,
pdfTemplateSchema,
} from "~/lib/appearance";
+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 };
}
+3
View File
@@ -29,6 +29,9 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
pathname.startsWith("/dashboard/businesses")
);
}
if (href === "/dashboard/time-clock") {
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
}
return pathname === href;
}
+117
View File
@@ -0,0 +1,117 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
import path from "path";
// 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 import("@aws-sdk/client-s3");
let s3ModulePromise: Promise<S3Module> | null = null;
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
async function getS3() {
if (!s3ModulePromise) {
s3ModulePromise = import("@aws-sdk/client-s3");
}
const mod = await s3ModulePromise;
if (!s3Client) {
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 MinIO and most S3-compatible endpoints.
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
});
}
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 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 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 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/");
}
+69
View File
@@ -0,0 +1,69 @@
import crypto from "crypto";
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 { db } from "~/server/db";
import { users } from "~/server/db/schema";
export type PasswordResetResult = {
success: boolean;
emailSent: boolean;
userEmail?: string;
};
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 = crypto.randomBytes(32).toString("hex");
const resetTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
await db
.update(users)
.set({ resetToken, resetTokenExpiry })
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return { success: true, emailSent: false, userEmail: user.email };
}
try {
const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: user.email,
userName: user.name ?? undefined,
resetToken,
resetUrl,
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: `beenvoice <noreply@${fromDomain}>`,
to: user.email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text,
});
return { success: true, emailSent: true, userEmail: user.email };
} catch (emailError) {
console.error("Failed to send password reset email:", emailError);
return { success: true, emailSent: false, userEmail: user.email };
}
}
+166 -38
View File
@@ -6,9 +6,19 @@ import {
Image,
StyleSheet,
pdf,
type Styles,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
import {
isFixedLineItem,
} from "~/lib/invoice-line-item";
import React from "react";
import {
type PdfFontFamily,
type ResolvedPdfFonts,
pdfFontCacheKey,
resolvePdfFonts,
} from "~/lib/pdf-fonts";
// Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void {
@@ -101,6 +111,8 @@ export interface InvoiceData {
export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string;
pdfFontFamily?: PdfFontFamily;
pdfNumericFontFamily?: PdfFontFamily;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
@@ -109,6 +121,8 @@ export interface PDFGenerationSettings {
const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFontFamily: "sans",
pdfNumericFontFamily: "mono",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
@@ -118,7 +132,95 @@ function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
const styles = StyleSheet.create({
function mapLegacyPdfFont(
fontFamily: string,
fonts: ResolvedPdfFonts,
): string {
switch (fontFamily) {
case "Helvetica-Bold":
return fonts.bold;
case "Helvetica":
return fonts.regular;
case "Courier-Bold":
return fonts.monoBold;
case "Courier":
return fonts.mono;
default:
return fontFamily;
}
}
function remapStyleFontFamilies<T extends Styles>(
sheet: T,
fonts: ResolvedPdfFonts,
): T {
const remapped = {} as T;
for (const [key, style] of Object.entries(sheet)) {
const fontFamily = (style as { fontFamily?: string }).fontFamily;
remapped[key as keyof T] = {
...style,
...(fontFamily
? { fontFamily: mapLegacyPdfFont(fontFamily, fonts) }
: {}),
} as T[keyof T];
}
return remapped;
}
type PdfStyleBundle = {
styles: typeof baseStyles;
minimalStyles: typeof baseMinimalStyles;
fonts: ResolvedPdfFonts;
getStatusStyle: (
status: string,
) => Array<Record<string, string | number>>;
};
const pdfStyleCache = new Map<string, PdfStyleBundle>();
function getPdfStyleBundle(
bodyFamily: PdfFontFamily,
numericFamily: PdfFontFamily,
): PdfStyleBundle {
const cacheKey = pdfFontCacheKey(bodyFamily, numericFamily);
const cached = pdfStyleCache.get(cacheKey);
if (cached) return cached;
const fonts = resolvePdfFonts(bodyFamily, numericFamily);
const styles = remapStyleFontFamilies(baseStyles, fonts);
const bundle: PdfStyleBundle = {
styles,
minimalStyles: baseMinimalStyles,
fonts,
getStatusStyle: (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
},
};
pdfStyleCache.set(cacheKey, bundle);
return bundle;
}
const baseStyles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
@@ -537,7 +639,7 @@ const styles = StyleSheet.create({
},
});
const minimalStyles = StyleSheet.create({
const baseMinimalStyles = StyleSheet.create({
page: {
fontSize: 9,
paddingTop: 28,
@@ -729,27 +831,6 @@ const getStatusLabel = (status: string) => {
}
};
const getStatusStyle = (status: string) => {
switch (status.toLowerCase()) {
case "paid":
return [styles.statusBadge, styles.statusPaid];
case "sent":
return [styles.statusBadge, styles.statusPaid];
case "overdue":
return [
styles.statusBadge,
{ backgroundColor: "#fef2f2", color: "#dc2626" },
];
case "draft":
return [
styles.statusBadge,
{ backgroundColor: "#f9fafb", color: "#9ca3af" },
];
default:
return [styles.statusBadge, styles.statusUnpaid];
}
};
function getColumnWidths(showRate: boolean) {
return showRate
? {
@@ -766,7 +847,9 @@ function getColumnWidths(showRate: boolean) {
const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1029,7 +1112,9 @@ const DenseHeader: React.FC<{
const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
showRate: boolean;
}> = ({ settings, showRate }) => {
pdfStyles: PdfStyleBundle;
}> = ({ settings, showRate, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const cols = getColumnWidths(showRate);
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1094,7 +1179,9 @@ const TableHeader: React.FC<{
const NotesSection: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
if (!invoice.notes) return null;
const isMinimal = settings.pdfTemplate === "minimal";
@@ -1129,9 +1216,11 @@ const NotesSection: React.FC<{
);
};
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => {
const Footer: React.FC<{
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const isMinimal = settings.pdfTemplate === "minimal";
return (
@@ -1151,7 +1240,7 @@ const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
<Text
style={{
fontSize: isMinimal ? 8 : 9,
fontFamily: "Helvetica",
fontFamily: fonts.regular,
color: "#6b7280",
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
@@ -1176,7 +1265,9 @@ const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => {
pdfStyles: PdfStyleBundle;
}> = ({ invoice, items, settings, pdfStyles }) => {
const { styles, minimalStyles, fonts } = pdfStyles;
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -1206,7 +1297,7 @@ const TotalsSection: React.FC<{
<Text
style={{
fontSize: isMinimal ? 8 : 11,
fontFamily: "Helvetica-Bold",
fontFamily: fonts.bold,
color: "#0f0f0f",
textAlign: isMinimal ? "left" : "center",
marginBottom: isMinimal ? 5 : 8,
@@ -1301,6 +1392,26 @@ export const InvoicePDF: React.FC<{
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings);
const pdfStyles = getPdfStyleBundle(
settings.pdfFontFamily,
settings.pdfNumericFontFamily,
);
return (
<InvoicePDFDocument
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
);
};
const InvoicePDFDocument: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
pdfStyles: PdfStyleBundle;
}> = ({ invoice, settings, pdfStyles }) => {
const { styles, minimalStyles } = pdfStyles;
const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
@@ -1313,7 +1424,11 @@ export const InvoicePDF: React.FC<{
size="LETTER"
style={[styles.page, isMinimal ? minimalStyles.page : {}]}
>
<DenseHeader invoice={invoice} settings={settings} />
<DenseHeader
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
{items.length > 0 && (
<View
@@ -1322,7 +1437,11 @@ export const InvoicePDF: React.FC<{
isMinimal ? minimalStyles.tableContainer : {},
]}
>
<TableHeader settings={settings} showRate={showRate} />
<TableHeader
settings={settings}
showRate={showRate}
pdfStyles={pdfStyles}
/>
{items.map(
(item, index) =>
item && (
@@ -1366,7 +1485,7 @@ export const InvoicePDF: React.FC<{
{ width: cols.hours },
]}
>
{item.hours}
{isFixedLineItem(item.hours) ? "—" : item.hours}
</Text>
{showRate && (
<Text
@@ -1404,12 +1523,21 @@ export const InvoicePDF: React.FC<{
wrap={false}
>
{invoice.notes && (
<NotesSection invoice={invoice} settings={settings} />
<NotesSection
invoice={invoice}
settings={settings}
pdfStyles={pdfStyles}
/>
)}
<TotalsSection invoice={invoice} items={items} settings={settings} />
<TotalsSection
invoice={invoice}
items={items}
settings={settings}
pdfStyles={pdfStyles}
/>
</View>
<Footer settings={settings} />
<Footer settings={settings} pdfStyles={pdfStyles} />
</Page>
</Document>
);
+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;
}
+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 };
});
}