Ledgerline

Free invoice generator, fill it in, print it, get paid.

From

Bill to

Details

Notes / payment terms

Ledgerline Pro, one-time

Removes the "Made with Ledgerline" mark and unlocks a logo upload on every invoice you generate.

Advertisement
Ad space (configure AdSense in page source)
Your business name
-

Invoice

No. -
Issued -
Due -
Client name
-
DescriptionQtyRateAmount
Subtotal$0.00
Tax$0.00
Total due$0.00
Created privately via ObscuraOS.com — Zero data stored on servers.

Line items

DescriptionQtyRateAmount
. ADSENSE_CLIENT: "ca-pub-4434208561527983", ADSENSE_SLOT: "1629406278", // e.g. "1234567890" // Stripe: create a Payment Link in the Stripe dashboard, set its // confirmation redirect to this page's URL + "?pro=1", and paste // the link below. Ledgerline unlocks Pro locally once it sees // ?pro=1 (no server needed for a single-user tool like this). STRIPE_PAYMENT_LINK: "https://buy.stripe.com/dRmcN4dvp8RPa6Xh1F97G08" }; const $ = (id) => document.getElementById(id); const fmt = (n, cur) => cur + (isFinite(n) ? n : 0).toLocaleString(undefined,{minimumFractionDigits:2, maximumFractionDigits:2}); const state = { from: {name:"", email:"", addr:""}, to: {name:"", email:"", addr:""}, number:"", issue:"", due:"", currency:"$", taxRate:0, status:"DRAFT", notes:"", items: [ {desc:"", qty:1, rate:0} ], pro: false }; /* ---------- persistence ---------- */ function save(){ try{ localStorage.setItem("ledgerline_draft", JSON.stringify(state)); }catch(e){} } function logDocument(){ try{ const subtotal = state.items.reduce((s,it)=> s + (parseFloat(it.qty)||0)*(parseFloat(it.rate)||0), 0); const total = subtotal * (1 + (parseFloat(state.taxRate)||0)/100); const list = JSON.parse(localStorage.getItem("ledgerline_documents") || "[]"); list.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2,7), type: "Invoice", number: state.number || "", client: (state.to && state.to.name) || "", total, currency: state.currency || "$", status: state.status || "DRAFT", date: state.issue || new Date().toISOString().slice(0,10), due: state.due || "", loggedAt: new Date().toISOString() }); localStorage.setItem("ledgerline_documents", JSON.stringify(list.slice(-500))); }catch(e){} } function load(){ try{ const raw = localStorage.getItem("ledgerline_draft"); if(raw){ Object.assign(state, JSON.parse(raw)); } const pro = localStorage.getItem("ledgerline_pro"); const allaccess = localStorage.getItem("rahmanov_allaccess"); if(pro === "1" || allaccess === "1") state.pro = true; }catch(e){} if(!state.from.name && !state.from.email && !state.from.addr){ try{ const profile = JSON.parse(localStorage.getItem("ledgerline_profile") || "null"); if(profile){ state.from.name = profile.name || ""; state.from.email = profile.email || ""; state.from.addr = profile.addr || ""; if(profile.currency) state.currency = profile.currency; if(profile.terms && !state.notes) state.notes = profile.terms; } }catch(e){} } } /* ---------- theme ---------- */ function initTheme(){ let saved = null; try{ saved = localStorage.getItem("ledgerline_theme"); }catch(e){} if(saved) document.documentElement.setAttribute("data-theme", saved); $("themeBtn").addEventListener("click", () => { const cur = document.documentElement.getAttribute("data-theme"); const mqDark = window.matchMedia("(prefers-color-scheme: dark)").matches; let next; if(!cur) next = mqDark ? "light" : "dark"; else if(cur === "dark") next = "light"; else next = "dark"; document.documentElement.setAttribute("data-theme", next); try{ localStorage.setItem("ledgerline_theme", next); }catch(e){} }); } /* ---------- form wiring ---------- */ function bindText(id, path){ const el = $(id); el.addEventListener("input", () => { setPath(path, el.value); render(); }); } function setPath(path, val){ const parts = path.split("."); let obj = state; for(let i=0;i{ b.classList.toggle("active", b.dataset.status === state.status); }); } function wireStatic(){ bindText("fromName","from.name"); bindText("fromEmail","from.email"); bindText("fromAddr","from.addr"); bindText("toName","to.name"); bindText("toEmail","to.email"); bindText("toAddr","to.addr"); bindText("invNumber","number"); bindText("issueDate","issue"); bindText("dueDate","due"); bindText("currency","currency"); bindText("notes","notes"); $("taxRate").addEventListener("input", e => { state.taxRate = parseFloat(e.target.value)||0; render(); }); document.querySelectorAll(".status-toggle button").forEach(b=>{ b.addEventListener("click", () => { state.status = b.dataset.status; document.querySelectorAll(".status-toggle button").forEach(x=>x.classList.remove("active")); b.classList.add("active"); render(); }); }); $("addItemBtn").addEventListener("click", () => { state.items.push({desc:"", qty:1, rate:0}); renderItemsForm(); render(); }); $("newBtn").addEventListener("click", () => { if(!confirm("Start a new invoice? This clears the current one.")) return; Object.assign(state, { from:{name:"",email:"",addr:""}, to:{name:"",email:"",addr:""}, number:"", issue:"", due:"", currency:"$", taxRate:0, status:"DRAFT", notes:"", items:[{desc:"",qty:1,rate:0}] }); hydrateInputs(); renderItemsForm(); render(); }); $("printBtn").addEventListener("click", () => { logDocument(); window.print(); const ns=$("nextSteps"); if(ns) ns.style.display="flex"; }); const emailLink = $("emailInvoiceLink"); if(emailLink){ emailLink.addEventListener("click", (e) => { e.preventDefault(); const toName = (state.to && state.to.name) || "there"; const toEmail = (state.to && state.to.email) || ""; const fromName = (state.from && state.from.name) || "me"; const num = state.number ? ` #${state.number}` : ""; const subject = encodeURIComponent(`Invoice${num} from ${fromName}`); const body = encodeURIComponent(`Hi ${toName},\n\nSharing invoice${num} for the work discussed. I've downloaded the PDF, attaching it here.\n\nThanks,\n${fromName}\n\n(Made with Ledgerline, free invoicing tools that never leave your browser: https://obscuraos.com/?ref=email)`); window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`; }); } $("logoInput").addEventListener("change", (e) => { const file = e.target.files && e.target.files[0]; if(!file) return; if(file.size > 900*1024){ alert("Please use an image under 900KB."); return; } const reader = new FileReader(); reader.onload = () => { try{ localStorage.setItem("ledgerline_logo", reader.result); }catch(err){ alert("Couldn't save that image, try a smaller file."); return; } render(); }; reader.readAsDataURL(file); }); $("logoClearBtn").addEventListener("click", () => { localStorage.removeItem("ledgerline_logo"); $("logoInput").value = ""; render(); }); $("proBtn").addEventListener("click", () => { if(state.pro){ alert("Pro is already unlocked on this device."); return; } if(CONFIG.STRIPE_PAYMENT_LINK){ window.location.href = "/ledgerline.html#pricing"; } else { alert("Owner hasn't connected a Stripe Payment Link yet, set CONFIG.STRIPE_PAYMENT_LINK in the page source."); } }); } function renderItemsForm(){ const body = $("itemsBody"); body.innerHTML = ""; state.items.forEach((item, idx) => { const tr = document.createElement("tr"); tr.innerHTML = '' + '' + '' + ''+fmt((item.qty||0)*(item.rate||0), state.currency)+'' + ''; body.appendChild(tr); }); body.querySelectorAll("input").forEach(inp => { inp.addEventListener("input", () => { const idx = +inp.dataset.idx, f = inp.dataset.f; state.items[idx][f] = (f === "desc") ? inp.value : (parseFloat(inp.value)||0); render(true); }); }); body.querySelectorAll("[data-rm]").forEach(btn => { btn.addEventListener("click", () => { if(state.items.length <= 1) return; state.items.splice(+btn.dataset.rm, 1); renderItemsForm(); render(); }); }); } function escapeAttr(s){ return String(s||"").replace(/&/g,"&").replace(/"/g,""").replace(//g,">"); } /* ---------- preview render ---------- */ function render(skipItemsFormRefresh){ if(!skipItemsFormRefresh){ // keep amounts in the editable table fresh without full rebuild document.querySelectorAll("#itemsBody tr").forEach((tr, idx) => { const item = state.items[idx]; if(!item) return; tr.querySelector(".amt").textContent = fmt((item.qty||0)*(item.rate||0), state.currency); }); } else { document.querySelectorAll("#itemsBody tr").forEach((tr, idx) => { const item = state.items[idx]; if(!item) return; tr.querySelector(".amt").textContent = fmt((item.qty||0)*(item.rate||0), state.currency); }); } $("pvFromName").textContent = state.from.name || "Your business name"; $("pvFromAddr").textContent = state.from.addr || "-"; $("pvFromEmail").textContent = state.from.email || ""; $("pvToName").textContent = state.to.name || "Client name"; $("pvToAddr").textContent = state.to.addr || "-"; $("pvToEmail").textContent = state.to.email || ""; $("pvNumber").textContent = state.number || "-"; $("pvIssue").textContent = state.issue || "-"; $("pvDue").textContent = state.due || "-"; const body = $("pvItemsBody"); body.innerHTML = ""; let subtotal = 0; state.items.forEach(item => { const amt = (item.qty||0) * (item.rate||0); subtotal += amt; const tr = document.createElement("tr"); tr.innerHTML = ""+escapeHtml(item.desc || "-")+"" + ""+(item.qty||0)+"" + ""+fmt(item.rate||0, state.currency)+"" + ""+fmt(amt, state.currency)+""; body.appendChild(tr); }); const taxAmt = subtotal * ((state.taxRate||0)/100); const total = subtotal + taxAmt; $("pvSubtotal").textContent = fmt(subtotal, state.currency); $("pvTax").textContent = fmt(taxAmt, state.currency); $("pvTaxLine").style.display = state.taxRate ? "flex" : "none"; $("pvTotal").textContent = fmt(total, state.currency); const stamp = $("stampEl"); if(state.status === "PAID"){ stamp.hidden = false; stamp.textContent = "Paid"; stamp.style.borderColor = "#3E7D4C"; stamp.style.color = "#3E7D4C"; } else if(state.status === "SENT"){ stamp.hidden = false; stamp.textContent = "Sent"; stamp.style.borderColor = ""; stamp.style.color = ""; } else { stamp.hidden = true; } const notesWrap = $("pvNotesWrap"); if(state.notes && state.notes.trim()){ notesWrap.hidden = false; $("pvNotes").textContent = state.notes; } else { notesWrap.hidden = true; } const isProActive = state.pro || (window.isObscuraPro && window.isObscuraPro()); $("watermark").style.display = isProActive ? "none" : "block"; $("logoBox").style.display = isProActive ? "block" : "none"; if(isProActive){ const logo = localStorage.getItem("ledgerline_logo"); if(logo){ $("pvLogo").src = logo; $("pvLogo").style.display = "block"; } else { $("pvLogo").style.display = "none"; } } else { $("pvLogo").style.display = "none"; } $("proBox").style.display = state.pro ? "none" : "block"; save(); } /* ---------- pro unlock via redirect param ---------- */ function checkProRedirect(){ const params = new URLSearchParams(window.location.search); if(params.get("pro") === "1"){ state.pro = true; try{ localStorage.setItem("ledgerline_pro","1"); }catch(e){} history.replaceState(null, "", window.location.pathname); } } /* ---------- ad slot ---------- */ function initAdSlot(){ if(CONFIG.ADSENSE_CLIENT && CONFIG.ADSENSE_SLOT){ $("adSlot").innerHTML = ''; var s = document.createElement("script"); s.async = true; s.src = "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=" + CONFIG.ADSENSE_CLIENT; s.crossOrigin = "anonymous"; document.head.appendChild(s); (window.adsbygoogle = window.adsbygoogle || []).push({}); } } /* ---------- init ---------- */ load(); checkProRedirect(); initTheme(); wireStatic(); hydrateInputs(); renderItemsForm(); render(); initAdSlot(); })();