Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone b3b80918bc912dda8c53a48d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/store.ts
// What Invoice remembers on this device: your business details and defaults, the invoice you
// are working on, your clients and items, and the invoices you shared (with their private keys).
// Nothing here leaves the browser.
import { CURRENCIES, addDays, isBlankItem, isDate, localDay, type InvoiceData, type Kind, type Party } from "../functions/lib/invoice";
export interface Defaults {
from: Party & { logo?: string };
currency: string;
/** The next invoice number, and the next estimate number. */
next: string;
nextEstimate: string;
taxRate?: string;
paymentInstructions?: string;
}
export interface Mine {
id: string;
key: string;
number: string;
client: string;
total: number;
currency: string;
issueDate: string;
dueDate: string;
at: number;
kind?: Kind;
}
function read<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function write(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch { /* storage full or blocked: the app still works for this visit */ }
}
function guessCurrency(): string {
try {
const region = new Intl.Locale(navigator.language).maximize().region || "US";
const byRegion: Record<string, string> = { US: "USD", GB: "GBP", CA: "CAD", AU: "AUD", NZ: "NZD", JP: "JPY", CH: "CHF", SE: "SEK", NO: "NOK", DK: "DKK", PL: "PLN", IN: "INR", SG: "SGD", HK: "HKD", MX: "MXN", BR: "BRL", ZA: "ZAR", IL: "ILS", KR: "KRW", AE: "AED" };
const euro = ["DE", "FR", "ES", "IT", "NL", "BE", "AT", "PT", "IE", "FI", "GR", "LU", "SK", "SI", "EE", "LV", "LT", "MT", "CY", "HR"];
return byRegion[region] || (euro.includes(region) ? "EUR" : "USD");
} catch {
return "USD";
}
}
export function loadDefaults(): Defaults {
const d = read<Defaults>("invoice:defaults");
return {
from: d?.from ?? { name: "" },
currency: d?.currency && CURRENCIES.includes(d.currency) ? d.currency : guessCurrency(),
next: d?.next || "INV-0001",
nextEstimate: d?.nextEstimate || "EST-0001",
...(d?.taxRate ? { taxRate: d.taxRate } : {}),
...(d?.paymentInstructions ? { paymentInstructions: d.paymentInstructions } : {}),
};
}
export function saveDefaults(patch: Partial<Defaults>) {
write("invoice:defaults", { ...loadDefaults(), ...patch });
}
export function blankItem() {
return { description: "", quantity: "1", unitPrice: "" };
}
/** A fresh invoice: your saved business details, the next number, due in 14 days. */
export function newDraft(kind: Kind = "invoice"): InvoiceData {
const d = loadDefaults();
const today = localDay();
return {
...(kind === "estimate" ? { kind } : {}),
from: d.from,
to: { name: "", email: "", address: "" },
number: kind === "estimate" ? d.nextEstimate : d.next,
issueDate: today,
dueDate: addDays(today, 14),
currency: d.currency,
items: [blankItem()],
...(d.taxRate ? { taxRate: d.taxRate } : {}),
discount: { type: "percent", value: "" },
notes: "",
paymentInstructions: d.paymentInstructions ?? "",
};
}
export function loadDraft(): InvoiceData {
const d = read<InvoiceData>("invoice:draft");
return d && d.from && d.to && Array.isArray(d.items) ? d : newDraft();
}
export function saveDraft(d: InvoiceData) {
write("invoice:draft", d);
}
/** True once a client or a line has been typed in. */
export function draftStarted(d: InvoiceData): boolean {
return Boolean(d.to.name.trim()) || d.items.some((it) => !isBlankItem(it));
}
/** A copy of a sent invoice to start the next one from: the next number, dated today, with the same payment terms. */
export function copyAsDraft(d: InvoiceData, kind: Kind = d.kind ?? "invoice"): InvoiceData {
const defaults = loadDefaults();
const today = localDay();
const terms = isDate(d.issueDate) && isDate(d.dueDate)
? Math.round((Date.parse(`${d.dueDate}T00:00:00Z`) - Date.parse(`${d.issueDate}T00:00:00Z`)) / 86_400_000)
: null;
const { kind: _, ...rest } = d;
return {
...rest,
...(kind === "estimate" ? { kind } : {}),
number: kind === "estimate" ? defaults.nextEstimate : defaults.next,
issueDate: today,
dueDate: terms === null ? "" : addDays(today, terms),
discount: d.discount ?? { type: "percent", value: "" },
notes: d.notes ?? "",
paymentInstructions: d.paymentInstructions ?? "",
};
}
/** Clients you invoiced from this device, most recent first, to fill in again. */
export function loadClients(): Party[] {
const list = read<Party[]>("invoice:clients");
return Array.isArray(list) ? list.filter((c) => c && typeof c.name === "string") : [];
}
/** Lines you billed before, with their price and currency, to fill in again. */
export interface SavedItem { description: string; unitPrice: string; currency: string }
export function loadItems(): SavedItem[] {
const list = read<SavedItem[]>("invoice:items");
return Array.isArray(list) ? list.filter((it) => it && typeof it.description === "string") : [];
}
/** Remembers the client and the lines of an invoice you downloaded or shared. */
export function rememberWork(d: InvoiceData) {
const name = d.to.name.trim();
if (name) {
const client: Party = { name, ...(d.to.email?.trim() ? { email: d.to.email.trim() } : {}), ...(d.to.address?.trim() ? { address: d.to.address.trim() } : {}) };
write("invoice:clients", [client, ...loadClients().filter((c) => c.name.toLowerCase() !== name.toLowerCase())].slice(0, 100));
}
const lines: SavedItem[] = d.items
.filter((it) => it.description.trim() && String(it.unitPrice).trim())
.map((it) => ({ description: it.description.trim(), unitPrice: String(it.unitPrice).trim(), currency: d.currency }));
if (lines.length) {
const seen = new Set(lines.map((it) => it.description.toLowerCase()));
write("invoice:items", [...lines, ...loadItems().filter((it) => !seen.has(it.description.toLowerCase()))].slice(0, 200));
}
}
export function loadMine(): Mine[] {
const list = read<Mine[]>("invoice:mine");
return Array.isArray(list) ? list : [];
}
export function rememberMine(entry: Mine) {
const list = loadMine().filter((m) => m.id !== entry.id);
write("invoice:mine", [entry, ...list].slice(0, 200));
}
export function keyFor(id: string): string | null {
return loadMine().find((m) => m.id === id)?.key ?? null;
}
Change history
Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync