/* Kas bridge v3-bootstrap. Requires Advanced Google Sheets service (Sheets v4). * Never log request bodies, passwords, signatures, or integration keys. * All modifications use ONE atomic Sheets batchUpdate, under ScriptLock. * Existing rows/formulas are read only; new cash rows are appended. */ var KAS_BOOK='1BFsa9bVkxo2WDuJyHGjxW1lnwWLKQei7PgEeLqfmqec'; var KAS_LEDGER='_KAS_RECEIPTS'; var KAS_REJECT_SAFE=false; var KAS_ACCOUNTS={kecil:[1,2,3],brankas:[9,10,11],laci:[13,14,15]}; function centsValue(value){ if(typeof value!=='number'||!Number.isFinite(value)||Math.abs(value*100-Math.round(value*100))>0.000001||!Number.isSafeInteger(Math.round(value*100)))throw Error('Saldo / nominal bukan angka USD dua desimal.'); return Math.round(value*100); } function cashRegions(rows){ var ends={kecil:rows.length,brankas:rows.length,laci:rows.length},totals=[],stocks=[]; rows.forEach(function(row,i){ if(i<3)return; if(String(row[0]||'').trim().toUpperCase()==='TOTAL')totals.push(i); if(String(row[8]||'').trim().toUpperCase()==='STOCK VOUCHER KANTOR')stocks.push(i); }); if(totals.length||stocks.length){ // Recognize only the verified split template, never guess a boundary from zeros. if(totals.length!==1||stocks.length!==1||stocks[0]<5||totals[0]<=stocks[0])throw Error('Batas tabel kas ambigu; admin perlu menetapkan layout.'); var separator=stocks[0]-1; for(var col=8;col<16;col++)if(rows[separator][col]!==''&&rows[separator][col]!==undefined)throw Error('Pemisah kas/voucher tidak kosong; admin perlu menetapkan layout.'); ends.kecil=totals[0];ends.brankas=ends.laci=separator; for(var i=separator;i80||/[\[\]:*?/\\\x00-\x1f]/.test(name)||name.indexOf('_KAS_')===0)throw Error('Nama tab tidak valid.');return name;} function validateId(p){if(!p||!/^[a-f0-9]{32}$/.test(p.id)||typeof p.actor!=='string'||p.actor.length>150)throw Error('Identitas transaksi tidak valid.');} function planTransaction(balances,p){ validateId(p);validName(p.tab); if(!Object.prototype.hasOwnProperty.call(KAS_ACCOUNTS,p.source)||['in','out','transfer'].indexOf(p.kind)<0||!Number.isSafeInteger(p.cents)||p.cents<1||p.cents>100000000||typeof p.note!=='string'||!p.note.trim()||p.note.length>200)throw Error('Transaksi tidak valid.'); if(p.kind==='transfer'&&(!Object.prototype.hasOwnProperty.call(KAS_ACCOUNTS,p.target)||p.target===p.source))throw Error('Tujuan transfer tidak valid.'); var next=Object.assign({},balances),row=Array(16).fill(''),cols=KAS_ACCOUNTS[p.source]; if(p.kind==='in'){next[p.source]+=p.cents;row[cols[1]]=p.cents/100;} else{if(next[p.source]10000||sheet.getLastColumn()>100)throw Error('Tab terlalu besar; perlu pemeriksaan admin.');return sheet.getRange(1,1,Math.max(sheet.getLastRow(),3),Math.max(sheet.getLastColumn(),16)).getValues();} function receipt(sheet,id){var rows=sheet.getDataRange().getValues();for(var i=1;i20000)throw Error('Tidak sah.'); var env=JSON.parse(e.postData.contents),secret=props.getProperty('KAS_SECRET'); if(!secret||secret.length<43||!/^\d{10}$/.test(env.timestamp)||Math.abs(Date.now()/1000-Number(env.timestamp))>180||!/^[a-f0-9]{32}$/.test(env.nonce)||typeof env.body!=='string')throw Error('Tidak sah.'); var expected=hex(Utilities.computeHmacSha256Signature(env.timestamp+'\n'+env.nonce+'\n'+env.body,secret)); if(!same(expected,env.signature))throw Error('Tidak sah.'); var key='NONCE_'+env.nonce; if(props.getProperty(key))throw Error('Permintaan sudah digunakan.'); var all=props.getProperties();Object.keys(all).forEach(function(k){if(k.indexOf('NONCE_')===0&&Number(all[k])200}; } function executeOperation(book,action,p){ validateId(p); var ledger=book.getSheetByName(KAS_LEDGER);if(!ledger)throw Error('Jalankan installKas sekali.'); var digest=hash(JSON.stringify({action:action,payload:p})),old=receipt(ledger,p.id); if(old){if(old.hash!==digest)throw Error('ID bentrok.');return old.result;} // Only a confirmed absent receipt permits a definite pre-write rejection. KAS_REJECT_SAFE=true; var requests=[],result={ok:true,id:p.id}; if(action==='transaction'){ var sheet=book.getSheetByName(validName(p.tab));if(!sheet)throw Error('Tab tidak ditemukan.'); var rows=rowsOf(sheet),balances=cashSnapshot(rows);requireAppendLayout(rows); var planned=planTransaction(balances,p); requests.push(appendRequest(sheet.getSheetId(),planned.row));result.balances=planned.balances; }else if(action==='period'||action==='bootstrap'){ validName(p.name);validName(p.source);if(book.getSheetByName(p.name))throw Error('Nama tab sudah ada.'); var source=book.getSheetByName(p.source);if(!source)throw Error('Sumber tidak ditemukan.'); var sourceRows=rowsOf(source),balances=cashSnapshot(sourceRows); if(action==='bootstrap'){ if(p.admin!==true||p.confirm!==true||p.source!=='NOVEMBER'||p.name!=='NOVEMBER APP')throw Error('Konfirmasi onboarding admin diperlukan.'); var approved=bootstrapSource(sourceRows); if(!same(p.sourceHash,approved.sourceHash))throw Error('Snapshot berubah; muat ulang dan konfirmasi kembali.'); result.sourceHash=approved.sourceHash; }else requireAppendLayout(sourceRows); var sheetId=Math.floor(Math.random()*2000000000)+1; while(book.getSheets().some(function(s){return s.getSheetId()===sheetId;}))sheetId=Math.floor(Math.random()*2000000000)+1; var title=Array(16).fill(''),headers=Array(16).fill(''),opening=Array(16).fill(''); title[0]='KAS KECIL';title[8]='BRANKAS LEADER';title[13]='LACI LEADER';title[5]='KAS_APPEND_V2'; headers[0]=headers[8]='TANGGAL';headers[4]=headers[12]='KETERANGAN'; Object.keys(KAS_ACCOUNTS).forEach(function(k){var c=KAS_ACCOUNTS[k];headers[c[0]]='KELUAR';headers[c[1]]='MASUK';headers[c[2]]='SALDO';opening[c[2]]=balances[k]/100;}); opening[4]=opening[12]='Saldo awal snapshot '+p.source+' · '+p.actor; requests.push({addSheet:{properties:{sheetId:sheetId,title:p.name,gridProperties:{rowCount:1000,columnCount:16,frozenRowCount:3}}}}); requests.push({updateCells:{start:{sheetId:sheetId,rowIndex:0,columnIndex:0},rows:[title,headers,opening].map(function(r){return {values:r.map(cell)};}),fields:'userEnteredValue'}}); result.tab=p.name;result.opening=balances; }else throw Error('Aksi tidak dikenal.'); requests.push(appendRequest(ledger.getSheetId(),[p.id,digest,JSON.stringify(result),new Date().toISOString(),p.actor,action])); KAS_REJECT_SAFE=false; Sheets.Spreadsheets.batchUpdate({requests:requests},KAS_BOOK); SpreadsheetApp.flush(); var saved=receipt(book.getSheetByName(KAS_LEDGER),p.id); if(!saved||saved.hash!==digest)throw Error('Konfirmasi belum terbaca. Ulangi ID yang sama.'); return saved.result; } function doPost(e){ KAS_REJECT_SAFE=false; var lock=LockService.getScriptLock(); try{ if(!lock.tryLock(25000))throw Error('Sibuk. Ulangi ID yang sama.'); var command=authenticate(e,PropertiesService.getScriptProperties()); var book=SpreadsheetApp.openById(KAS_BOOK),result; if(command.action==='state')result=stateOf(book,command.payload||{}); else result=executeOperation(book,command.action,command.payload); return jsonResponse(result); }catch(err){return jsonResponse({ok:false,rejected:KAS_REJECT_SAFE,error:'Permintaan ditolak. Periksa konfigurasi/header/saldo atau ulangi ID yang sama.'});} finally{if(lock.hasLock())lock.releaseLock();} } function doGet(){return jsonResponse({ok:false,error:'Akses hanya melalui server Kas bertanda tangan.'});} function jsonResponse(data){return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON);} function installKas(){ var lock=LockService.getScriptLock();lock.waitLock(25000); try{ var props=PropertiesService.getScriptProperties();if(!props.getProperty('KAS_SECRET')||props.getProperty('KAS_SECRET').length<43)throw Error('Isi KAS_SECRET di Script Properties terlebih dahulu.'); var book=SpreadsheetApp.openById(KAS_BOOK);if(book.getSheetByName(KAS_LEDGER))return 'Ledger sudah tersedia; tidak diubah.'; var sheetId=Math.floor(Math.random()*2000000000)+1; Sheets.Spreadsheets.batchUpdate({requests:[{addSheet:{properties:{sheetId:sheetId,title:KAS_LEDGER,hidden:true,gridProperties:{rowCount:1000,columnCount:6}}}},{updateCells:{start:{sheetId:sheetId,rowIndex:0,columnIndex:0},rows:[{values:['ID','SHA256','RESULT','TIME','ACTOR','ACTION'].map(cell)}],fields:'userEnteredValue'}}]},KAS_BOOK); return 'Kas siap. Tidak ada baris/formula lama yang diubah.'; }finally{lock.releaseLock();} }