invoice-free.openapps.xyz

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.
Owned app · rev 333971bf96d2
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.
← All files
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
333971bf96 · 5 hours ago
Bounded source sync
088b53bddb · 1 day ago
Bounded source sync
3f3e9ff8f9 · 1 day ago
Bounded source sync
209bec4267 · 1 day ago
Bounded source sync
11f3d98cda · 1 day ago
Bounded source sync
722948ebc3 · 1 day ago
Bounded source sync
3e9d04b2c0 · 1 day ago