/* Rentsprig app: properties and tenants, rent receipts, rent ledger, reminder letter, move-in/move-out checklists with photos, * live preview, PDF/print, Pro features. Everything stays in this browser. © 2026 CyberMax. All rights reserved. */ (function () { "use strict"; var R = window.Rent, P = window.RentPDF, X = window.CMXPro, PH = window.RentPhotos; var KEY = "rentsprig:v1"; var HUB = "https://cybermax-tools-cybermax.static.hf.space/rentsprig/index.html?utm_source=pdf-credit"; var CREDIT = "Made with Rentsprig by CyberMax · free rent receipt generator"; var $ = function (id) { return document.getElementById(id); }; var qa = function (sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); }; function pro() { return X.isPro(); } function toast(m) { X.toast(m); } function upsell(msg, from) { X.upsell(msg || "That is a Rentsprig Pro feature.", from); } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } /* ---------- state ---------- */ function firstOfMonth() { return R.todayISO().slice(0, 8) + "01"; } function addDays(iso, n) { var p = iso.split("-"), d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2] + n)); return d.toISOString().slice(0, 10); } function blankReceipt() { return { no: "", date: R.todayISO(), tenantId: "", from: "", address: "", periodFrom: firstOfMonth(), periodTo: R.monthEnd(firstOfMonth()), amount: "", method: "Bank transfer", methodOther: "", methodRef: "", by: (S && S.landlord && S.landlord.name) || "", balance: "0", note: "", signature: "", post: true }; } function blankLetter() { return { tenantId: "", tenant: "", property: "", date: R.todayISO(), amount: "", dueDate: firstOfMonth(), payBy: addDays(R.todayISO(), 7), lateFee: "", howToPay: "", contact: "", landlord: "", subject: R.LETTER_SUBJECT, body: R.LETTER_BODY }; } function fresh() { return { landlord: { name: "", address: "", phone: "", email: "" }, currency: guessCurrency(), page: "", logo: null, properties: [], tenants: [], receipt: null, receipts: [], counter: 0, ledgers: {}, ledgerTenant: "", letter: null, checklists: [], clId: "", cmp: { in: "", out: "" }, tab: "receipt", results: 0 }; } function guessCurrency() { try { var loc = navigator.language || "en-US", tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; if (/^en-GB/.test(loc) || /London/.test(tz)) return "GBP"; if (/^en-CA|^fr-CA/.test(loc)) return "CAD"; if (/^en-AU/.test(loc) || /^Australia/.test(tz)) return "AUD"; if (/^en-NZ/.test(loc)) return "NZD"; if (/^en-IN/.test(loc) || /Kolkata|Calcutta/.test(tz)) return "INR"; if (/Johannesburg/.test(tz)) return "ZAR"; if (/^Europe\//.test(tz)) return "EUR"; } catch (e) { /* default */ } return "USD"; } var S = null; function load() { var s = null; try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; } var f = fresh(); if (!s || typeof s !== "object") s = f; Object.keys(f).forEach(function (k) { if (s[k] == null) s[k] = f[k]; }); return s; } var firstVisit = false; try { firstVisit = !localStorage.getItem(KEY); } catch (e) { firstVisit = true; } S = load(); if (firstVisit) { // First visit: show a finished receipt and letter straight away (example data you overwrite as you type). var ex0 = R.example(); S.landlord = ex0.landlord; S.currency = "USD"; S.receipt = ex0.receipt; S.receipt.no = ""; S.receipt.tenantId = ""; S.letter = ex0.letter; S.letter.tenantId = ""; } if (!S.receipt) S.receipt = blankReceipt(); if (!S.letter) S.letter = blankLetter(); var saveT = null; function save() { clearTimeout(saveT); saveT = setTimeout(saveNow, 150); } function saveNow() { clearTimeout(saveT); try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save in this browser (storage full or private mode)."); } } function ctx() { return { landlord: S.landlord, currency: S.currency }; } function page() { return S.page || R.cur(S.currency).page; } function tenant(id) { return S.tenants.filter(function (t) { return t.id === id; })[0] || null; } function prop(id) { return S.properties.filter(function (p) { return p.id === id; })[0] || null; } function checklist() { return S.checklists.filter(function (c) { return c.id === S.clId; })[0] || S.checklists[0] || null; } var cmpMode = false; /* ---------- tabs ---------- */ var docTab = S.tab === "setup" ? "receipt" : S.tab; function switchTab(t, focus) { S.tab = t; if (t !== "setup") docTab = t; qa(".tabs [role=tab]").forEach(function (b) { var on = b.dataset.tab === t; b.setAttribute("aria-selected", String(on)); b.tabIndex = on ? 0 : -1; if (on && focus) b.focus(); }); qa(".panel").forEach(function (p) { p.hidden = p.id !== "panel-" + t; }); if (t === "checklist") ensureChecklist(); save(); renderPanel(); renderPreview(); } qa(".tabs [role=tab]").forEach(function (b, i, all) { b.addEventListener("click", function () { switchTab(b.dataset.tab); }); b.addEventListener("keydown", function (e) { var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (d) { e.preventDefault(); switchTab(all[(i + d + all.length) % all.length].dataset.tab, true); } }); }); /* ---------- forms <-> model ---------- */ function model(form) { var k = form.dataset.form; if (k === "receipt") return S.receipt; if (k === "letter") return S.letter; if (k === "checklist") return checklist(); if (k === "landlord") return S.landlord; return null; } function toForm(form) { var m = model(form); if (!m) return; qa("[name]", form).forEach(function (el) { var v = form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page") ? S[el.name] : m[el.name]; if (el.type === "checkbox") el.checked = !!v && (el.name !== "post" || pro()); else el.value = v == null ? "" : v; }); } function allForms() { qa("form[data-form]").forEach(toForm); syncReceiptUi(); } qa("form[data-form]").forEach(function (form) { form.addEventListener("submit", function (e) { e.preventDefault(); }); form.addEventListener("input", function (e) { var el = e.target, m = model(form); if (!el.name || !m) return; if (form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page")) S[el.name] = el.value; else if (el.type === "checkbox") { if (el.name === "post" && !pro()) { el.checked = false; return upsell("Posting receipts to the rent ledger is part of Pro.", "ledger-post"); } m[el.name] = el.checked; } else m[el.name] = el.value; if (form.dataset.form === "receipt") syncReceiptUi(); if (form.dataset.form === "checklist" && el.name === "date") renderPicker(); changed(); }); }); function syncReceiptUi() { qa(".r-other").forEach(function (el) { el.hidden = S.receipt.method !== "Other"; }); $("r-no-hint").textContent = pro() ? "numbered for you" : "type your own"; } /* tenant pickers */ function tenantLabel(t) { var p = prop(t.propertyId); return t.name + (t.unit ? " · " + t.unit : "") + (p ? " · " + p.name : ""); } function renderTenantSelects() { qa(".tenant-select").forEach(function (sel) { var kind = sel.dataset.fill, cur = kind === "ledger" ? S.ledgerTenant : kind === "receipt" ? S.receipt.tenantId : kind === "letter" ? S.letter.tenantId : (checklist() || {}).tenantId; sel.innerHTML = (kind === "ledger" ? "" : '") + S.tenants.map(function (t) { return '"; }).join(""); if (kind === "ledger" && !S.tenants.length) sel.innerHTML = ''; sel.value = cur && tenant(cur) ? cur : (kind === "ledger" && S.tenants[0] ? S.tenants[0].id : ""); if (kind === "ledger") S.ledgerTenant = sel.value; }); } qa(".tenant-select").forEach(function (sel) { sel.addEventListener("change", function () { var t = tenant(sel.value), kind = sel.dataset.fill; if (kind === "ledger") { S.ledgerTenant = sel.value; renderLedger(); return changed(); } if (!t) return; var p = prop(t.propertyId), addr = R.addressOf(p, t); if (kind === "receipt") { var r = S.receipt, from = R.isISO(r.date) ? r.date.slice(0, 8) + "01" : firstOfMonth(); r.tenantId = t.id; r.from = t.name; r.address = addr; r.amount = t.rent || r.amount; r.periodFrom = from; r.periodTo = R.monthEnd(from); if (!r.by) r.by = S.landlord.name || ""; toForm($("panel-receipt")); } else if (kind === "letter") { var l = S.letter, bal = R.ledger(S.ledgers[t.id] || []).balance; l.tenantId = t.id; l.tenant = t.name; l.property = addr; l.amount = pro() && bal > 0 ? String(bal) : t.rent || l.amount; l.dueDate = R.dueDate(R.todayISO(), t.dueDay); if (!l.landlord) l.landlord = S.landlord.name || ""; if (!l.contact) l.contact = [S.landlord.phone, S.landlord.email].filter(Boolean).join(" or "); toForm($("panel-letter")); } else if (kind === "checklist") { var c = checklist(); if (!c) return; c.tenantId = t.id; c.tenant = t.name; c.property = [p && p.name, t.unit].filter(Boolean).join(", "); if (!c.by) c.by = S.landlord.name || ""; toForm($("c-form")); renderPicker(); } changed(); }); }); /* ---------- receipts ---------- */ function noteNumber(no) { var v = R.receiptValue(no); if (v != null && v > (S.counter || 0)) S.counter = v; } function renderReceiptHistory() { var body = $("r-hist-body"); $("btn-receipts-csv").hidden = !pro(); if (!pro()) { body.innerHTML = '
Pro numbers your receipts (R-0001, R-0002…), keeps every one you make, reopens any of them and exports the list as a CSV.
Receipts you download or print appear here.
'; return; } body.innerHTML = '| No. | Date | From | Amount | |
|---|---|---|---|---|
| ' + esc(r.no) + " | " + esc(R.fmtDate(r.date, r.currency)) + " | " + esc(r.from) + ' | ' + esc(R.money(R.parseNum(r.amount), r.currency)) + ' |
Add a tenant under Properties & tenants, or press Load example.
'; return; } var list = S.ledgers[tid] || [], L = R.ledger(list), bal = {}; L.rows.forEach(function (r) { bal[r.id] = r.balance; }); var dis = locked ? " disabled" : ""; $("l-rows").innerHTML = list.map(function (e, i) { var id = "le" + i; return 'No entries yet. Add this month\'s rent to start.
'; } $("l-rows").addEventListener("input", function (e) { var row = e.target.closest(".lrow"); if (!row || !pro()) return; var t = S.ledgerTenant, en = (S.ledgers[t] || [])[+row.dataset.i]; if (!en) return; en[e.target.dataset.k] = e.target.value; changed(); var bal = {}; R.ledger(S.ledgers[t]).rows.forEach(function (r) { bal[r.id] = r.balance; }); qa(".lrow", $("l-rows")).forEach(function (r) { var x = S.ledgers[t][+r.dataset.i]; r.querySelector(".l-bal").textContent = x && x.id in bal ? "Balance " + R.money(bal[x.id], S.currency) : ""; }); }); $("l-rows").addEventListener("click", function (e) { var b = e.target.closest("[data-del]"); if (!b || !pro()) return; (S.ledgers[S.ledgerTenant] || []).splice(+b.dataset.del, 1); renderLedger(); changed(); }); function addEntry(type) { if (!pro()) return upsell("The rent ledger is part of Pro.", "ledger"); var t = tenant(S.ledgerTenant); if (!t) return toast("Add a tenant first."); var list = S.ledgers[t.id] = S.ledgers[t.id] || [], today = R.todayISO(); var e = { id: R.uid("e"), date: today, type: type, desc: "", amount: "" }; if (type === "rent") { e.date = R.dueDate(today, t.dueDay); e.amount = t.rent || ""; try { e.desc = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(e.date + "T00:00:00Z")) + " rent"; } catch (x) { e.desc = "Rent"; } } else if (type === "payment") { e.desc = "Payment"; e.amount = String(Math.max(0, R.ledger(list).balance) || ""); } else if (type === "late") e.desc = "Late fee per lease"; list.push(e); renderLedger(); changed(); var ins = qa("#l-rows input[data-k=amount]"); if (ins.length) ins[ins.length - 1].focus(); } $("btn-l-rent").addEventListener("click", function () { addEntry("rent"); }); $("btn-l-pay").addEventListener("click", function () { addEntry("payment"); }); $("btn-l-late").addEventListener("click", function () { addEntry("late"); }); $("btn-l-row").addEventListener("click", function () { addEntry("charge"); }); $("btn-ledger-csv").addEventListener("click", function () { if (!pro()) return upsell("CSV export of the rent ledger is part of Pro.", "ledger-csv"); var t = tenant(S.ledgerTenant); if (!t) return toast("Pick a tenant first."); X.download("rent-ledger-" + R.fileName({ title: t.name }, "csv"), R.ledgerCsv(R.ledger(S.ledgers[t.id] || []), t), "text/csv;charset=utf-8"); result(null); }); $("btn-ledger-pro").addEventListener("click", function () { upsell(null, "ledger"); }); /* ---------- letter ---------- */ $("btn-letter-reset").addEventListener("click", function () { S.letter.subject = R.LETTER_SUBJECT; S.letter.body = R.LETTER_BODY; toForm($("panel-letter")); changed(); toast("Letter text reset to the template."); }); /* ---------- checklists ---------- */ function ensureChecklist() { if (!S.checklists.length) { var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.push(c); S.clId = c.id; save(); } if (!checklist() || S.clId !== checklist().id) S.clId = checklist().id; } function clLabel(c) { return (c.kind === "out" ? "Move-out" : "Move-in") + " · " + (c.tenant || c.property || "untitled") + " · " + R.fmtDate(c.date, S.currency); } function renderPicker() { var c = checklist(); $("c-pick").innerHTML = S.checklists.map(function (x) { return '"; }).join(""); if (c) $("c-pick").value = c.id; var ins = S.checklists.filter(function (x) { return x.kind === "in"; }), outs = S.checklists.filter(function (x) { return x.kind === "out"; }); $("c-cin").innerHTML = ins.map(function (x) { return '"; }).join("") || ''; $("c-cout").innerHTML = outs.map(function (x) { return '"; }).join("") || ''; if (S.cmp.in && ins.some(function (x) { return x.id === S.cmp.in; })) $("c-cin").value = S.cmp.in; if (S.cmp.out && outs.some(function (x) { return x.id === S.cmp.out; })) $("c-cout").value = S.cmp.out; var used = S.checklists.length; $("c-quota").textContent = pro() ? "Pro: unlimited checklists, photos and comparison reports." : "Free: " + Math.min(used, 1) + " of 1 move-in checklist used, without photos. Pro adds more checklists, photos and the move-out comparison."; $("c-quota").className = "quota" + (!pro() && used >= 1 ? " out" : ""); } $("c-pick").addEventListener("change", function () { S.clId = this.value; cmpMode = false; toForm($("c-form")); renderTenantSelects(); renderRooms(); changed(); }); $("c-cin").addEventListener("change", function () { S.cmp.in = this.value; if (cmpMode) changed(); }); $("c-cout").addEventListener("change", function () { S.cmp.out = this.value; if (cmpMode) changed(); }); $("btn-c-newin").addEventListener("click", function () { if (!pro() && S.checklists.length >= R.FREE.checklists) return upsell("The free plan includes 1 move-in checklist. Pro adds unlimited checklists, photos and the move-out comparison.", "checklist-limit"); var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.unshift(c); S.clId = c.id; cmpMode = false; toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("New move-in checklist."); }); $("btn-c-newout").addEventListener("click", function () { if (!pro()) return upsell("Move-out checklists and the comparison report are part of Pro.", "move-out"); var src = checklist(); if (!src) return; var base = src.kind === "in" ? src : S.checklists.filter(function (x) { return x.kind === "in" && x.tenantId === src.tenantId; })[0] || src; var c = R.newChecklist("out", { tenantId: base.tenantId, tenant: base.tenant, property: base.property, by: base.by || S.landlord.name, rooms: base.rooms }); S.checklists.unshift(c); S.clId = c.id; S.cmp = { in: base.kind === "in" ? base.id : S.cmp.in, out: c.id }; cmpMode = false; toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("Move-out checklist made from the move-in rooms. Record each item's condition now."); }); $("btn-c-del").addEventListener("click", function () { var c = checklist(); if (!c || !confirm("Delete this checklist and its photos?")) return; c.rooms.forEach(function (r) { r.items.forEach(function (it) { (it.photos || []).forEach(function (k) { PH.del(k); }); }); }); S.checklists = S.checklists.filter(function (x) { return x.id !== c.id; }); S.clId = S.checklists[0] ? S.checklists[0].id : ""; cmpMode = false; ensureChecklist(); toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); }); $("btn-c-compare").addEventListener("click", function () { if (!pro()) return upsell("The move-out comparison report is part of Pro.", "compare"); S.cmp.in = $("c-cin").value; S.cmp.out = $("c-cout").value; if (!S.cmp.in || !S.cmp.out) return toast("You need a move-in and a move-out checklist to compare."); cmpMode = true; $("btn-c-back").hidden = false; renderPreview(); toast("Comparison ready. Download it as a PDF."); if (window.innerWidth < 980) $("preview").scrollIntoView({ behavior: "smooth", block: "start" }); }); $("btn-c-back").addEventListener("click", function () { cmpMode = false; this.hidden = true; renderPreview(); }); function renderRooms() { var c = checklist(), box = $("c-rooms"); if (!c) { box.innerHTML = ""; return; } box.innerHTML = c.rooms.map(function (r, ri) { var done = r.items.filter(function (it) { return it.cond; }).length; return 'No properties yet.
'; $("s-tenants").innerHTML = S.tenants.length ? 'No tenants yet.
'; $("n-prop").innerHTML = S.properties.map(function (p) { return '"; }).join("") || ''; var q = $("s-quota"); q.textContent = pro() ? "Pro: unlimited properties and tenants." : "Free plan: " + Math.min(S.properties.length, 1) + " of " + R.FREE.properties + " property and " + Math.min(S.tenants.length, R.FREE.tenants) + " of " + R.FREE.tenants + " tenants used. Pro makes them unlimited."; q.className = "quota" + (!pro() && (S.properties.length >= R.FREE.properties || S.tenants.length >= R.FREE.tenants) ? " out" : ""); } $("f-prop").addEventListener("submit", function (e) { e.preventDefault(); var name = $("p-name").value.trim(), address = $("p-address").value.trim(); if (!pro() && S.properties.length >= R.FREE.properties) return upsell("The free plan holds 1 property. Pro adds unlimited properties and tenants.", "property-limit"); if (!name) { $("p-name").focus(); return toast("Give the property a name, e.g. its street address."); } S.properties.push({ id: R.uid("p"), name: name, address: address || name }); $("p-name").value = ""; $("p-address").value = ""; saveNow(); renderAll(); toast("Added " + name + "."); }); var editing = ""; $("f-tenant").addEventListener("submit", function (e) { e.preventDefault(); var f = this, v = {}; qa("[name]", f).forEach(function (el) { v[el.name] = el.value.trim(); }); if (!v.name) { $("n-name").focus(); return toast("Add the tenant's name."); } if (!S.properties.length) return toast("Add a property first."); if (!editing && !pro() && S.tenants.length >= R.FREE.tenants) return upsell("The free plan holds 2 tenants. Pro adds unlimited properties and tenants.", "tenant-limit"); if (editing) { var t = tenant(editing); if (t) Object.keys(v).forEach(function (k) { t[k] = v[k]; }); } else S.tenants.push(Object.assign({ id: R.uid("t") }, v)); editing = ""; f.reset(); $("btn-add-tenant").textContent = "Add tenant"; $("btn-tenant-cancel").hidden = true; saveNow(); renderAll(); toast("Saved " + v.name + "."); }); $("btn-tenant-cancel").addEventListener("click", function () { editing = ""; $("f-tenant").reset(); this.hidden = true; $("btn-add-tenant").textContent = "Add tenant"; }); $("panel-setup").addEventListener("click", function (e) { var b = e.target.closest("[data-delprop],[data-deltenant],[data-edtenant]"); if (!b) return; if (b.dataset.delprop) { var p = prop(b.dataset.delprop), n = S.tenants.filter(function (t) { return t.propertyId === p.id; }); if (!confirm("Delete " + p.name + (n.length ? " and its " + n.length + " tenant(s)" : "") + "?")) return; S.properties = S.properties.filter(function (x) { return x.id !== p.id; }); S.tenants = S.tenants.filter(function (t) { return t.propertyId !== p.id; }); } else if (b.dataset.deltenant) { var t = tenant(b.dataset.deltenant); if (!confirm("Delete " + t.name + "? Their ledger is deleted too.")) return; S.tenants = S.tenants.filter(function (x) { return x.id !== t.id; }); delete S.ledgers[t.id]; } else { var te = tenant(b.dataset.edtenant); editing = te.id; qa("#f-tenant [name]").forEach(function (el) { el.value = te[el.name] || ""; }); $("btn-add-tenant").textContent = "Save tenant"; $("btn-tenant-cancel").hidden = false; $("n-name").focus(); return; } saveNow(); renderAll(); }); /* logo */ $("f-logo").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Your logo on every document is part of Pro.", "logo"); } }); $("f-logo").addEventListener("change", function () { var f = this.files && this.files[0]; this.value = ""; if (!f) return; if (f.size > 8e6) return toast("That image is over 8 MB. Try a smaller logo."); shrink(f).then(function (p) { if (!p) return; var s = Math.min(1, 600 / p.w, 240 / p.h); if (s < 1) { var img = new Image(); img.onload = function () { var cv = document.createElement("canvas"); cv.width = Math.round(p.w * s); cv.height = Math.round(p.h * s); cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height); S.logo = { data: cv.toDataURL("image/jpeg", 0.9), w: cv.width, h: cv.height }; saveNow(); logoUi(); renderPreview(); toast("Logo added."); }; img.src = p.data; } else { S.logo = p; saveNow(); logoUi(); renderPreview(); toast("Logo added."); } }); }); $("btn-logo-clear").addEventListener("click", function () { S.logo = null; saveNow(); logoUi(); renderPreview(); }); function logoUi() { var has = pro() && S.logo && S.logo.data; $("logo-prev").innerHTML = has ? '" + esc(r[1] || "-") + "
" + esc(b.sub) + "
" : "") + "' + esc(b.text) + "
"; case "space": return ''; case "table": var cell = function (c, i, tag) { var o = c && typeof c === "object" ? c : { v: c }; return "<" + tag + (b.cols[i].a === "r" ? ' class="r"' : "") + (o.color || o.bold ? ' style="' + (o.color ? "color:" + o.color + ";" : "") + (o.bold ? "font-weight:700" : "") + '"' : "") + ">" + esc(o.v) + "" + tag + ">"; }; return '| ' + esc(c.t) + " | "; }).join("") + "
|---|