Invoice Generator - Create simple, professional invoices as PDF.
import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { jsPDF } from "jspdf";
interface LineItem {
description: string;
qty: string;
rate: string;
}
interface Invoice {
number: string;
date: string;
dueDate: string;
currency: string;
fromName: string;
fromAddress: string;
fromEmail: string;
fromTaxId: string;
toName: string;
toAddress: string;
toEmail: string;
items: LineItem[];
taxPercent: string;
taxNote: string;
paymentInfo: string;
notes: string;
}
const today = new Date().toISOString().split("T")[0];
const STORAGE_KEY = "typebulb_invoice";
const blank: Invoice = {
number: "1",
date: today,
dueDate: "Due on Receipt",
currency: "USD",
fromName: "",
fromAddress: "",
fromEmail: "",
fromTaxId: "",
toName: "",
toAddress: "",
toEmail: "",
items: [{ description: "", qty: "", rate: "" }],
taxPercent: "0",
taxNote: "",
paymentInfo: "",
notes: "",
};
const fmtMoney = (n: number) =>
n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtDate = (s: string) => {
const d = new Date(s + "T00:00:00");
if (isNaN(d.getTime())) return s;
return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "2-digit" });
};
const calcSubtotal = (items: LineItem[]) =>
items.reduce((sum, it) => sum + (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0), 0);
function buildPDF(inv: Invoice) {
const doc = new jsPDF();
const pw = doc.internal.pageSize.getWidth();
const m = 20;
const cw = pw - 2 * m;
let y = 25;
const gray = () => doc.setTextColor(100);
const black = () => doc.setTextColor(30);
const bold = () => doc.setFont("helvetica", "bold");
const norm = () => doc.setFont("helvetica", "normal");
const fs = (s: number) => doc.setFontSize(s);
// Title
bold(); fs(22); black();
doc.text("INVOICE", pw / 2, y, { align: "center" });
y += 16;
// Invoice details
fs(9.5);
bold(); gray();
doc.text("Invoice Number:", m, y);
norm(); black();
doc.text(inv.number, m + 33, y);
bold(); gray();
doc.text("Invoice Date:", pw / 2 + 5, y);
norm(); black();
doc.text(fmtDate(inv.date), pw / 2 + 5 + 28, y);
y += 6;
bold(); gray();
doc.text("Due Date:", pw / 2 + 5, y);
norm(); black();
doc.text(inv.dueDate, pw / 2 + 5 + 28, y);
y += 12;
// Divider
doc.setDrawColor(220);
doc.setLineWidth(0.3);
doc.line(m, y, m + cw, y);
y += 10;
// From / Bill To headers
bold(); fs(8); gray();
doc.text("From:", m, y);
doc.text("Bill To:", pw / 2 + 5, y);
y += 6;
// Names
fs(10); bold(); black();
doc.text(inv.fromName, m, y);
doc.text(inv.toName, pw / 2 + 5, y);
y += 5.5;
// Addresses
norm(); fs(9.5);
const fAddr = inv.fromAddress.split("\n").filter(Boolean);
const tAddr = inv.toAddress.split("\n").filter(Boolean);
for (let i = 0; i < Math.max(fAddr.length, tAddr.length); i++) {
if (fAddr[i]) doc.text(fAddr[i], m, y);
if (tAddr[i]) doc.text(tAddr[i], pw / 2 + 5, y);
y += 5;
}
if (inv.fromEmail || inv.toEmail) {
gray();
if (inv.fromEmail) doc.text("Email: " + inv.fromEmail, m, y);
if (inv.toEmail) doc.text("Email: " + inv.toEmail, pw / 2 + 5, y);
y += 5;
}
if (inv.fromTaxId) {
gray();
doc.text("Tax ID: " + inv.fromTaxId, m, y);
y += 5;
}
black();
y += 10;
// Table
const col = [m, m + 90, m + 118, m + 145];
doc.setFillColor(245, 245, 245);
doc.rect(m, y - 1, cw, 9, "F");
bold(); fs(8.5); gray();
doc.text("Description", col[0] + 3, y + 5.5);
doc.text("Qty", col[1] + 3, y + 5.5);
doc.text("Rate (" + inv.currency + ")", col[2] + 3, y + 5.5);
doc.text("Amount (" + inv.currency + ")", col[3] + 3, y + 5.5);
y += 9;
doc.setDrawColor(200);
doc.line(m, y, m + cw, y);
y += 2;
norm(); fs(9.5); black();
inv.items.forEach((it) => {
const q = parseFloat(it.qty) || 0;
const r = parseFloat(it.rate) || 0;
y += 6;
doc.text(it.description, col[0] + 3, y);
doc.text(it.qty || "0", col[1] + 3, y);
doc.text("$" + fmtMoney(r), col[2] + 3, y);
doc.text("$" + fmtMoney(q * r), col[3] + 3, y);
});
y += 4;
doc.setDrawColor(220);
doc.line(m, y, m + cw, y);
y += 10;
// Totals
const sub = calcSubtotal(inv.items);
const taxPct = parseFloat(inv.taxPercent) || 0;
const tax = sub * taxPct / 100;
const total = sub + tax;
const tl = col[2] + 3;
const tv = m + cw - 3;
fs(9.5);
bold(); gray();
doc.text("Subtotal:", tl, y);
norm(); black();
doc.text(inv.currency + " $" + fmtMoney(sub), tv, y, { align: "right" });
y += 6;
bold(); gray();
doc.text("Tax:", tl, y);
norm(); black();
doc.text(inv.currency + " $" + fmtMoney(tax), tv, y, { align: "right" });
if (inv.taxNote) {
y += 4;
gray(); fs(8);
doc.text("(" + inv.taxNote + ")", tv, y, { align: "right" });
fs(9.5); black();
}
y += 7;
doc.setDrawColor(180);
doc.line(tl - 3, y - 2, m + cw, y - 2);
bold(); fs(11); gray();
doc.text("Total Due:", tl, y + 2);
black();
doc.text(inv.currency + " $" + fmtMoney(total), tv, y + 2, { align: "right" });
y += 16;
// Payment Info
if (inv.paymentInfo.trim()) {
fs(9); bold(); gray();
doc.text("Payment Information:", m, y);
y += 6;
norm(); black(); fs(9.5);
const lines = doc.splitTextToSize(inv.paymentInfo, cw);
doc.text(lines, m, y);
y += lines.length * 4.5 + 8;
}
// Notes
if (inv.notes.trim()) {
fs(9); bold(); gray();
doc.text("Notes:", m, y);
y += 6;
norm(); black(); fs(9.5);
const lines = doc.splitTextToSize(inv.notes, cw);
doc.text(lines, m, y);
}
const blob = doc.output("blob");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "invoice_" + inv.number + ".pdf";
a.click();
URL.revokeObjectURL(url);
}
function loadSaved(): Invoice {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return blank;
return { ...blank, ...JSON.parse(raw) };
} catch {
return blank;
}
}
const App = () => {
const [inv, setInv] = useState<Invoice>(loadSaved);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(inv)); } catch {}
}, [inv]);
const newInvoice = () => {
const nextNum = String((parseInt(inv.number) || 0) + 1);
setInv({ ...inv, number: nextNum, date: today, dueDate: "Due on Receipt", toName: "", toAddress: "", toEmail: "", items: [{ description: "", qty: "", rate: "" }], notes: "" });
};
const set = <K extends keyof Invoice>(k: K, v: Invoice[K]) =>
setInv((p) => ({ ...p, [k]: v }));
const setItem = (i: number, field: keyof LineItem, val: string) => {
const next = [...inv.items];
next[i] = { ...next[i], [field]: val };
set("items", next);
};
const addItem = () => set("items", [...inv.items, { description: "", qty: "", rate: "" }]);
const removeItem = (i: number) => {
if (inv.items.length <= 1) return;
set("items", inv.items.filter((_, idx) => idx !== i));
};
const sub = calcSubtotal(inv.items);
const taxPct = parseFloat(inv.taxPercent) || 0;
const tax = sub * taxPct / 100;
const total = sub + tax;
const ua = navigator.userAgent;
const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes("Safari")) || /; wv\)/.test(ua);
if (isWebView) return (
<div className="container">
<div className="in-app-banner">
Your in-app browser can't download files. Please open this page directly in your browser.
<button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
Copy Link
</button>
</div>
</div>
);
return (
<div className="container">
<header className="main-header">
<h1>Invoice Generator</h1>
<p className="subtitle">Create simple, professional invoices. Runs entirely in your browser.</p>
</header>
<div className="form-card">
<div className="row-4">
<div className="field">
<label>Invoice #</label>
<input value={inv.number} onChange={(e) => set("number", e.target.value)} />
</div>
<div className="field">
<label>Date</label>
<input type="date" value={inv.date} onChange={(e) => set("date", e.target.value)} />
</div>
<div className="field">
<label>Due Date</label>
<input value={inv.dueDate} onChange={(e) => set("dueDate", e.target.value)} />
</div>
<div className="field">
<label>Currency</label>
<input value={inv.currency} onChange={(e) => set("currency", e.target.value)} />
</div>
</div>
</div>
<div className="form-row-2">
<div className="form-card">
<h3>From</h3>
<div className="field">
<label>Name</label>
<input value={inv.fromName} onChange={(e) => set("fromName", e.target.value)} placeholder="Your name" />
</div>
<div className="field">
<label>Address</label>
<textarea rows={3} value={inv.fromAddress} onChange={(e) => set("fromAddress", e.target.value)} placeholder="Street address City, State, ZIP" />
</div>
<div className="field">
<label>Email</label>
<input type="email" value={inv.fromEmail} onChange={(e) => set("fromEmail", e.target.value)} placeholder="[email protected]" />
</div>
<div className="field">
<label>Tax ID</label>
<input value={inv.fromTaxId} onChange={(e) => set("fromTaxId", e.target.value)} placeholder="Optional" />
</div>
</div>
<div className="form-card">
<h3>Bill To</h3>
<div className="field">
<label>Name</label>
<input value={inv.toName} onChange={(e) => set("toName", e.target.value)} placeholder="Client name" />
</div>
<div className="field">
<label>Address</label>
<textarea rows={3} value={inv.toAddress} onChange={(e) => set("toAddress", e.target.value)} placeholder="Street address City, State, ZIP" />
</div>
<div className="field">
<label>Email</label>
<input type="email" value={inv.toEmail} onChange={(e) => set("toEmail", e.target.value)} placeholder="[email protected]" />
</div>
</div>
</div>
<div className="form-card">
<h3>Line Items</h3>
<table className="items-table">
<thead>
<tr>
<th className="col-desc">Description</th>
<th className="col-qty">Qty</th>
<th className="col-rate">Rate ({inv.currency})</th>
<th className="col-amount">Amount</th>
<th className="col-action"></th>
</tr>
</thead>
<tbody>
{inv.items.map((item, i) => {
const amt = (parseFloat(item.qty) || 0) * (parseFloat(item.rate) || 0);
return (
<tr key={i}>
<td><input value={item.description} onChange={(e) => setItem(i, "description", e.target.value)} placeholder="Service description" /></td>
<td><input type="number" value={item.qty} onChange={(e) => setItem(i, "qty", e.target.value)} placeholder="0" /></td>
<td><input type="number" value={item.rate} onChange={(e) => setItem(i, "rate", e.target.value)} placeholder="0" step="any" /></td>
<td className="amount-cell">${fmtMoney(amt)}</td>
<td><button className="remove-btn" onClick={() => removeItem(i)} disabled={inv.items.length <= 1}>×</button></td>
</tr>
);
})}
</tbody>
</table>
<button className="add-btn" onClick={addItem}>+ Add Line Item</button>
</div>
<div className="form-card">
<div className="row-tax">
<div className="field">
<label>Tax %</label>
<input type="number" value={inv.taxPercent} onChange={(e) => set("taxPercent", e.target.value)} step="any" />
</div>
<div className="field tax-note-field">
<label>Tax Note</label>
<input value={inv.taxNote} onChange={(e) => set("taxNote", e.target.value)} placeholder="e.g. not applicable — non-resident supplier" />
</div>
</div>
</div>
<div className="form-row-2">
<div className="form-card">
<h3>Payment Information</h3>
<textarea rows={4} value={inv.paymentInfo} onChange={(e) => set("paymentInfo", e.target.value)} placeholder="Bank name, BSB, account number, etc." />
</div>
<div className="form-card">
<h3>Notes</h3>
<textarea rows={4} value={inv.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Additional notes..." />
</div>
</div>
<div className="totals-card">
<div className="total-row">
<span>Subtotal</span>
<span>{inv.currency} ${fmtMoney(sub)}</span>
</div>
<div className="total-row">
<span>Tax ({inv.taxPercent}%)</span>
<span>{inv.currency} ${fmtMoney(tax)}</span>
</div>
<div className="total-row total-due">
<span>Total Due</span>
<span>{inv.currency} ${fmtMoney(total)}</span>
</div>
</div>
<div className="action-row">
<button className="action-btn" onClick={newInvoice}>New Invoice</button>
<button className="action-btn" onClick={() => buildPDF(inv)}>Download PDF</button>
</div>
</div>
);
};
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);