가계부 · Wealth Ledger (VND)Wealth Ledger
VND Real-time Household Account Book
Quick input
Data integrity normal
Total assets
₫0
Savings this month: ₫0
Spending plan
Weekly Spending Plan
₫0 / ₫0
Monthly spending plan
₫0 / ₫0
Increase your savings by reducing your actual spending compared to your plan.
Initial balance
The initial balance is immediately reflected in the cumulative total.
This cannot be undone. We recommend that you make a backup.
Income/Expenses for the past 12 months
monthly savings
| date | account | category | Category | memo | amount | work |
|---|
`;
const blob = new Blob([html], {type:'application/vnd.ms-excel'});
downloadBlob(blob, `ledger_vnd_${todayStr()}.xls`, 'application/vnd.ms-excel');
}
function backupJSON(){
const payload = {
settings, tx
};
const json = '\uFEFF'+JSON.stringify(payload);
downloadBlob(json, `ledger_vnd_backup_${todayStr()}.json`, 'application/json;charset=utf-8;');
}
function restoreJSON(e){
const f = e.target.files[0];
if(!f) return;
const reader = new FileReader();
reader.onload = function(){
try{
const obj = JSON.parse(reader.result);
if(!obj || !Array.isArray(obj.tx) || !obj.settings) throw new Error('bad file');
// minimal validation
settings = {
...defaultSettings(),
...obj.settings,
budgets: {
weekly: toInt(obj.settings?.budgets?.weekly||0),
monthly: toInt(obj.settings?.budgets?.monthly||0)
},
initBalances:{
cash: toInt(obj.settings?.initBalances?.cash||0),
biz: toInt(obj.settings?.initBalances?.biz||0),
personal: toInt(obj.settings?.initBalances?.personal||0),
}
};
tx = obj.tx.map(n=>({
id: n.id || uid(),
type: n.type==='in'?'in':'out',
account: ['cash','biz','personal'].includes(n.account)?n.account:'cash',
amount: toInt(n.amount||0),
date: n.date || fmtDateInput(new Date()),
category: n.category||'',
memo: n.memo||'',
createdAt: n.createdAt || Date.now()
}));
saveSettings(); saveTx();
// Refill inputs
els.langSelect.value = settings.lang;
applyI18N();
applyTheme(settings.theme);
setAmtInput(els.inpWeeklyPlan, settings.budgets.weekly);
setAmtInput(els.inpMonthlyPlan, settings.budgets.monthly);
setAmtInput(els.initCash, settings.initBalances.cash);
setAmtInput(els.initBiz, settings.initBalances.biz);
setAmtInput(els.initPersonal, settings.initBalances.personal);
drawAll();
showToast(t('imported'));
els.restoreFile.value = '';
}catch(err){
alert('Invalid backup file.');
}
};
reader.readAsText(f);
}// Helpers
function escCsv(s){
return (s||'').replace(/[\r\n]+/g,' ');
}
function csvCell(s){
if(s==null) return '';
const str = String(s);
if(/[",\n]/.test(str)) return `"${str.replace(/"/g,'""')}"`;
return str;
}
function downloadBlob(content, filename, mime){
let blob = content instanceof Blob ? content : new Blob([content], {type:mime});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(()=>{ URL.revokeObjectURL(url); a.remove(); }, 100);
}
function todayStr(){
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
function uid(){
return 'id_'+Math.random().toString(36).slice(2,10)+Date.now().toString(36);
}
function escapeHtml(s){
return (s||'').replace(/[&<>"']/g, c=>({ '&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function debounce(fn,ms){
let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); };
}
function alertI18n(key){ alert(t(key)); }// -------------- Lightweight self-test (doesn't change user data) --------------
// Ensures integrity math and formatter work. Runs on isolated data.
(function selfTest(){
try{
// pure compute with synthetic tx
const init = {cash: 1000, biz: 0, personal: 0};
const tempTx = [
{type:'in', account:'cash', amount: 2000, date:'2025-01-10'},
{type:'out', account:'cash', amount: 500, date:'2025-01-11'},
{type:'in', account:'biz', amount: 300, date:'2025-01-12'},
];
// compute
let c = {...init};
for(const r of tempTx){ c[r.account] += (r.type==='in'?1:-1)*r.amount; }
if(c.cash!==2500 || c.biz!==300 || c.personal!==0) throw new Error('balance mismatch');
// parse/format
if(parseAmt('1,234,567 VND')!==1234567) throw new Error('parseAmt failed');
formatVND(123); // should not throw
// ok
}catch(e){
console.warn('Self-test failed', e);
}
})();})();