@@ -562,16 +538,6 @@ Subtotal: ${formatCurrency(subtotal)}${
}
Total: ${formatCurrency(total)}
-${
- invoice.notes?.trim()
- ? `
-NOTES
-═══════════════
-${invoice.notes.trim()}
-`
- : ""
-}
-
ATTACHMENT
═══════════════
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
diff --git a/src/lib/pdf-export.tsx b/src/lib/pdf-export.tsx
index c8753e9..d5a6620 100644
--- a/src/lib/pdf-export.tsx
+++ b/src/lib/pdf-export.tsx
@@ -363,7 +363,6 @@ const styles = StyleSheet.create({
// Table styles
tableContainer: {
- flex: 1,
marginBottom: 20,
},
@@ -607,144 +606,6 @@ const getStatusStyle = (status: string) => {
}
};
-const PDF_PAGE_USABLE_HEIGHT = 672;
-const TABLE_HEADER_HEIGHT = 28;
-const TABLE_BOTTOM_MARGIN = 20;
-const FIRST_PAGE_HEADER_RESERVE = 285;
-const CONTINUATION_HEADER_RESERVE = 50;
-const TOTALS_HEIGHT = 108;
-
-function estimateWrappedLines(text: string, charsPerLine: number): number {
- const paragraphs = text.split(/\r?\n/);
-
- return paragraphs.reduce((total, paragraph) => {
- const words = paragraph.trim().split(/\s+/).filter(Boolean);
- if (words.length === 0) return total + 1;
-
- let lines = 1;
- let currentLineLength = 0;
-
- for (const word of words) {
- if (word.length > charsPerLine) {
- const longWordLines = Math.ceil(word.length / charsPerLine);
- if (currentLineLength > 0) {
- lines += longWordLines;
- currentLineLength = word.length % charsPerLine;
- } else {
- lines += longWordLines - 1;
- currentLineLength = word.length % charsPerLine;
- }
- continue;
- }
-
- const nextLength =
- currentLineLength === 0
- ? word.length
- : currentLineLength + 1 + word.length;
-
- if (nextLength > charsPerLine) {
- lines++;
- currentLineLength = word.length;
- } else {
- currentLineLength = nextLength;
- }
- }
-
- return total + lines;
- }, 0);
-}
-
-function estimateBottomSectionHeight(notes?: string | null): number {
- if (!notes?.trim()) return 20 + TOTALS_HEIGHT;
-
- const notesWidth = 240;
- const charsPerLine = Math.max(1, Math.floor(notesWidth / (10 * 0.45)));
- const noteLines = estimateWrappedLines(notes, charsPerLine);
- const notesHeight = 24 + 17 + noteLines * 10 * 1.4;
-
- return 20 + Math.max(TOTALS_HEIGHT, notesHeight);
-}
-
-function pageContentBudget(
- isFirstPage: boolean,
- options: { reserveBottom?: boolean; notes?: string | null } = {},
-): number {
- // 792pt page - 40pt paddingTop - 80pt paddingBottom = 672pt usable
- let h = PDF_PAGE_USABLE_HEIGHT;
- h -= isFirstPage ? FIRST_PAGE_HEADER_RESERVE : CONTINUATION_HEADER_RESERVE;
- h -= TABLE_HEADER_HEIGHT;
- h -= TABLE_BOTTOM_MARGIN;
- if (options.reserveBottom) {
- h -= estimateBottomSectionHeight(options.notes);
- }
- return h;
-}
-
-function estimateRowHeight(
- item: NonNullable
[0]>,
- showRate: boolean,
-): number {
- // 532pt usable width (612 - 80pt horizontal padding); description takes 40% or 48%
- const descColWidth = 532 * (showRate ? 0.4 : 0.48);
- // Frutiger at 10pt: 0.45em gives ~47 chars/line, matching real wrap behaviour
- const charsPerLine = Math.max(1, Math.floor(descColWidth / (10 * 0.45)));
- const lines = estimateWrappedLines(item.description || " ", charsPerLine);
- return Math.max(24, lines * 10 * 1.4 + 20);
-}
-
-function paginateItems(
- items: NonNullable,
- notes?: string | null,
- showRate = true,
-) {
- const validItems = items.filter(Boolean) as NonNullable<(typeof items)[0]>[];
- if (validItems.length === 0) return [[]];
-
- const rowHeights = validItems.map((item) =>
- estimateRowHeight(item, showRate),
- );
-
- function pack(startIdx: number, budget: number): number {
- let used = 0,
- count = 0;
- for (let i = startIdx; i < validItems.length; i++) {
- if (used + rowHeights[i]! > budget) break;
- used += rowHeights[i]!;
- count++;
- }
- return Math.max(1, count);
- }
-
- const pages: (typeof validItems)[] = [];
- let idx = 0;
-
- while (idx < validItems.length) {
- const isFirst = pages.length === 0;
- const finalPageCount = pack(
- idx,
- pageContentBudget(isFirst, { reserveBottom: true, notes }),
- );
-
- if (idx + finalPageCount >= validItems.length) {
- pages.push(validItems.slice(idx));
- break;
- }
-
- let count = pack(idx, pageContentBudget(isFirst));
-
- // If the rows fit only when this is not the final page, leave at least one
- // row for the final page so notes/totals are never squeezed below the table.
- if (idx + count >= validItems.length) {
- count = Math.max(1, validItems.length - idx - 1);
- }
-
- pages.push(validItems.slice(idx, idx + count));
- idx += count;
- }
-
- return pages;
-}
-
function getColumnWidths(showRate: boolean) {
return showRate
? {
@@ -865,27 +726,6 @@ const DenseHeader: React.FC<{
);
-// Abridged header component (other pages)
-const AbridgedHeader: React.FC<{
- invoice: InvoiceData;
- settings: Required;
-}> = ({ invoice, settings }) => (
-
-
- {invoice.business?.name ?? "Your Business Name"}
-
-
- INVOICE
-
- {invoice.invoicePrefix ?? "#"}
- {invoice.invoiceNumber}
-
-
-
-);
-
// Table header component
const TableHeader: React.FC<{
settings: Required;
@@ -1064,7 +904,7 @@ const TotalsSection: React.FC<{
};
// Main PDF component
-const InvoicePDF: React.FC<{
+export const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
@@ -1073,110 +913,88 @@ const InvoicePDF: React.FC<{
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
const cols = getColumnWidths(showRate);
- const paginatedItems = paginateItems(items, invoice.notes, showRate);
return (
- {paginatedItems.map((pageItems, pageIndex) => {
- const isFirstPage = pageIndex === 0;
- const isLastPage = pageIndex === paginatedItems.length - 1;
- const hasItems = pageItems.length > 0;
+
+
- return (
-
- {/* Header */}
- {isFirstPage ? (
-
- ) : (
-
- )}
-
- {/* Table */}
- {hasItems && (
-
-
- {pageItems.map(
- (item, index) =>
- item && (
- 0 && (
+
+
+ {items.map(
+ (item, index) =>
+ item && (
+
+
+ {formatDate(item.date)}
+
+
+ {item.description}
+
+
+ {item.hours}
+
+ {showRate && (
+
-
- {formatDate(item.date)}
-
-
- {item.description}
-
-
- {item.hours}
-
- {showRate && (
-
- {formatCurrency(item.rate, currency)}
-
- )}
-
- {formatCurrency(item.amount, currency)}
-
-
- ),
- )}
-
+ {formatCurrency(item.rate, currency)}
+
+ )}
+
+ {formatCurrency(item.amount, currency)}
+
+
+ ),
)}
+
+ )}
- {/* Bottom section with notes and totals (only on last page) */}
- {isLastPage && (
-
- {invoice.notes && }
-
-
- )}
+
+ {invoice.notes && }
+
+
- {/* Footer */}
-
-
- );
- })}
+
+
);
};
diff --git a/src/server/api/routers/email.ts b/src/server/api/routers/email.ts
index 3c88a74..1647662 100644
--- a/src/server/api/routers/email.ts
+++ b/src/server/api/routers/email.ts
@@ -7,6 +7,16 @@ import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
+function plainTextToHtml(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ .replace(/\n/g, "
");
+}
+
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
@@ -106,7 +116,6 @@ export const emailRouter = createTRPCRouter({
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
currency: invoice.currency,
- notes: invoice.notes,
client: {
name: invoice.client.name,
email: invoice.client.email,
@@ -115,7 +124,11 @@ export const emailRouter = createTRPCRouter({
items: invoice.items,
},
customContent: input.customContent,
- customMessage: input.customMessage,
+ customMessage:
+ input.customMessage ??
+ (invoice.emailMessage
+ ? plainTextToHtml(invoice.emailMessage)
+ : undefined),
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts
index 83fa6e0..5af94be 100644
--- a/src/server/api/routers/invoices.ts
+++ b/src/server/api/routers/invoices.ts
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
dueDate: z.date(),
status: z.enum(["draft", "sent", "paid"]).default("draft"),
notes: z.string().optional().or(z.literal("")),
+ emailMessage: z.string().optional().or(z.literal("")),
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
@@ -156,6 +157,8 @@ export const invoicesRouter = createTRPCRouter({
? null
: invoiceData.businessId,
notes: invoiceData.notes === "" ? null : invoiceData.notes,
+ emailMessage:
+ invoiceData.emailMessage === "" ? null : invoiceData.emailMessage,
};
// Verify business exists and belongs to user (if provided)
@@ -263,6 +266,14 @@ export const invoicesRouter = createTRPCRouter({
...(invoiceData.notes !== undefined
? { notes: invoiceData.notes === "" ? null : invoiceData.notes }
: {}),
+ ...(invoiceData.emailMessage !== undefined
+ ? {
+ emailMessage:
+ invoiceData.emailMessage === ""
+ ? null
+ : invoiceData.emailMessage,
+ }
+ : {}),
};
// Verify invoice exists and belongs to user
diff --git a/src/server/api/routers/settings.ts b/src/server/api/routers/settings.ts
index ec789fb..ece8d0d 100644
--- a/src/server/api/routers/settings.ts
+++ b/src/server/api/routers/settings.ts
@@ -94,6 +94,7 @@ const InvoiceBackupSchema = z.object({
totalAmount: z.number().default(0),
taxRate: z.number().default(0),
notes: z.string().optional(),
+ emailMessage: z.string().optional(),
items: z.array(InvoiceItemBackupSchema),
});
@@ -562,6 +563,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes ?? undefined,
+ emailMessage: invoice.emailMessage ?? undefined,
items: invoice.items,
})),
};
@@ -641,6 +643,7 @@ export const settingsRouter = createTRPCRouter({
totalAmount: invoiceData.totalAmount,
taxRate: invoiceData.taxRate,
notes: invoiceData.notes,
+ emailMessage: invoiceData.emailMessage,
createdById: userId,
})
.returning({ id: invoices.id });
diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts
index 8aa7353..63b1dbd 100644
--- a/src/server/db/migrate.ts
+++ b/src/server/db/migrate.ts
@@ -237,6 +237,9 @@ async function isMigrationApplied(client: Pool, tag: string): Promise {
"pdfTemplate",
);
}
+ if (tag === "0007_invoice_email_message") {
+ return columnExists(client, "public", "beenvoice_invoice", "emailMessage");
+ }
// Unknown migration — assume not applied so it runs
return false;
}
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 4102828..c98369f 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -320,6 +320,7 @@ export const invoices = createTable(
totalAmount: d.real().notNull().default(0),
taxRate: d.real().notNull().default(0.0),
notes: d.varchar({ length: 1000 }),
+ emailMessage: d.varchar({ length: 2000 }),
currency: d.varchar({ length: 3 }).default("USD").notNull(),
createdById: d
.varchar({ length: 255 })
diff --git a/src/types/invoice.ts b/src/types/invoice.ts
index bd5b6f8..7c1b63d 100644
--- a/src/types/invoice.ts
+++ b/src/types/invoice.ts
@@ -12,6 +12,7 @@ export interface Invoice {
totalAmount: number;
taxRate: number;
notes: string | null;
+ emailMessage: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;