From 768554557f88d275681b8cf2499159ed8acc9895 Mon Sep 17 00:00:00 2001 From: "Efrain (Helgen)" Date: Tue, 20 Oct 2026 17:47:45 -0600 Subject: [PATCH] Cobranza: carpeta, libro, subcarpetas y comprobantes en acceso restringido Workspace de helgen.tech comparte como lector con todo el dominio lo que se crea: la carpeta y el libro de cobranza nacieron visibles para cualquier vendedor. privado_() los deja en Restringido (conserva a tania@ y leo@) y prepararCobranza() repara los que ya existen. Prueba: el simulacro de Drive comparte lo nuevo con el dominio. CLAUDE.md: IDs de la carpeta y el libro de cobranza reales. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 ++++++- Code.js | 33 ++++++++++++++++++++++----------- tests/cobranza.test.js | 18 ++++++++++++++---- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 325170f..51cc04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ autorrelleno, y claves SAT internas por producto para contabilidad (con sugerenc | Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` | | Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) | | "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) | -| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | Fuera de la carpeta maestra, en Mi unidad. Se crea con `prepararCobranza()` (ID pendiente: anotarlo aquí al crearla en la API real). Lectores: tania@ y leo@ (§6.11) | +| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | `1A9uoRdhFdWOBacWCxx4Ktw1d9iQH7JRc` (creada el 20/10/2026 con `prepararCobranza()`; fuera de la carpeta maestra, en Mi unidad). Libro "Cotizador Helgen — Cobranza (datos)" (`COBRANZA_DB_ID`) `1uk9FNzTkQPjvWJw2R5wb8HXZl9c8ldYvyB64c8yjHL0`. Lectores: tania@ y leo@ (§6.11) | | Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) | | Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) | | Respaldos antes de Cobranza | Carpeta "Respaldos 2026-10-20 (antes de Cobranza)" `1C_yGbX0Gk_cZBwegdswPFvuzCVDdyTLF` (en la carpeta maestra): Base de datos `14C3d7w2ouaygtCGi0BP1QocGiqG4gf5Umh9O4KHgdow`, Catálogo `140_1sfwXF9u_aL1egDPYSbsE0rMwELGPZ5rkGVdzQnc` (antes de las columnas U:V, la columna I del catálogo y la pestaña "Tipo de cambio"). Verificadas contra los originales | @@ -381,6 +381,11 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A - **Datos en un libro aparte** dentro de la carpeta "Cotizador Helgen — Cobranza", fuera de la carpeta maestra: la Base de datos y la carpeta maestra están compartidas como **lector con todo el dominio helgen.tech** (revisado el 20/10/2026), así que una pestaña Pagos ahí la vería cualquier vendedor. **Nunca poner datos de cobranza en la Base de datos.** +- **Workspace comparte con todo helgen.tech lo que se crea** (pasó el 20/10/2026: la carpeta y el libro de cobranza nacieron + con "helgen.tech: lector" aunque el código no compartía nada). Por eso todo lo de cobranza (carpeta, libro, subcarpetas de + año y proyecto, comprobantes) pasa por `privado_()`, que lo deja en "Restringido" y conserva a las personas agregadas una por + una. `prepararCobranza()` repara carpeta y libro si alguien los vuelve a compartir. Si se crean archivos en otra área privada, + hacer lo mismo. - **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza; `ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de cobranza (solo `permisos` en falso para ocultar la pestaña). diff --git a/Code.js b/Code.js index 716a099..9d62bd9 100644 --- a/Code.js +++ b/Code.js @@ -1049,12 +1049,21 @@ function exigirCobranza_() { if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.'); } -/** Carpeta de cobranza. Se crea sola en Mi unidad (sin compartir). Si se borró, error: no se crea otra a escondidas. */ +/** + * Deja un archivo o carpeta de cobranza en "Restringido": quita el acceso por enlace o de todo el dominio y conserva a + * las personas agregadas una por una (tania@, leo@). Workspace comparte con todo helgen.tech lo que se crea (§6.11). + */ +function privado_(item) { + if (item.getSharingAccess() !== DriveApp.Access.PRIVATE) item.setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.NONE); + return item; +} + +/** Carpeta de cobranza. Se crea sola en Mi unidad (restringida). Si se borró, error: no se crea otra a escondidas. */ function carpetaCobranza_() { const props = PropertiesService.getScriptProperties(); const id = props.getProperty('COBRANZA_FOLDER_ID'); if (!id) { - const carpeta = DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA); + const carpeta = privado_(DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA)); props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId()); return carpeta; } @@ -1063,7 +1072,7 @@ function carpetaCobranza_() { if (!carpeta || carpeta.isTrashed()) { throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.'); } - return carpeta; + return privado_(carpeta); } /** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */ @@ -1098,7 +1107,7 @@ function abrirLibroCobranza_(id) { function crearLibroCobranza_() { const carpeta = carpetaCobranza_(); const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA); - DriveApp.getFileById(ss.getId()).moveTo(carpeta); + privado_(DriveApp.getFileById(ss.getId())).moveTo(carpeta); Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre)); const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1'); if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante); @@ -1466,9 +1475,9 @@ function registrarPago(p) { const hojaPagos = d.hojas.pagos; const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_'); const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4); - const carpeta = subcarpeta_(subcarpeta_(carpetaCobranza_(), anio), proyecto.folio); - const archivo = carpeta.createFile(Utilities.newBlob(bytes, tipo.mime, - nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext))); + const carpeta = privado_(subcarpeta_(privado_(subcarpeta_(carpetaCobranza_(), anio)), proyecto.folio)); + const archivo = privado_(carpeta.createFile(Utilities.newBlob(bytes, tipo.mime, + nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext)))); const t = textoCelda; hojaPagos.appendRow([ @@ -1511,13 +1520,15 @@ function cancelarPago(folioPago, motivo) { } /** - * Correr UNA vez desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad, sin compartir) y su - * libro. También sirve para autorizar permisos antes de publicar (§6.8). No comparte nada. + * Correr desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad) y su libro, y deja los dos en + * "Restringido" (también si ya existían). Sirve para autorizar permisos antes de publicar (§6.8). No comparte nada. */ function prepararCobranza() { const libro = libroCobranza_(); - Logger.log('Carpeta de cobranza: ' + carpetaCobranza_().getUrl()); - Logger.log('Libro de cobranza: ' + libro.getUrl()); + const archivoLibro = privado_(DriveApp.getFileById(libro.getId())); + const carpeta = carpetaCobranza_(); + Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (acceso: ' + carpeta.getSharingAccess() + ')'); + Logger.log('Libro de cobranza: ' + libro.getUrl() + ' (acceso: ' + archivoLibro.getSharingAccess() + ')'); Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)')); } diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index 90a61b0..aec170d 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -34,11 +34,13 @@ function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:() // ---- Drive simulado ---- let nId=0; const carpetas={}, archivos={}; -function carpeta(nombre,padre){const f={id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false, +// Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN') +const compartible=o=>Object.assign(o,{acceso:'DOMAIN',getSharingAccess:()=>o.acceso,setSharing:(a,p)=>{o.acceso=a;o.permiso=p}}); +function carpeta(nombre,padre){const f=compartible({id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false}); Object.assign(f,{ getId:()=>f.id,getName:()=>f.nombre,getUrl:()=>'https://drive/'+f.id,isTrashed:()=>f.trashed, getFoldersByName:n=>{const l=f.hijos.filter(h=>h.nombre===n);let i=0;return{hasNext:()=>il[i++]}}, createFolder:n=>{const h=carpeta(n,f);f.hijos.push(h);return h}, - createFile:b=>{const id='A'+(++nId);const a={id,blob:b,padre:f};archivos[id]=a;f.archivos.push(a);return{getId:()=>id,getUrl:()=>'https://drive/file/'+id}}}; + createFile:b=>{const id='A'+(++nId);const a=compartible({id,blob:b,padre:f});archivos[id]=a;f.archivos.push(a);return Object.assign(a,{getId:()=>id,getUrl:()=>'https://drive/file/'+id})}}); carpetas[f.id]=f;return f} const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)'); const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre; @@ -71,12 +73,14 @@ const ctx={console:{...console,warn(){}},Date,JSON, return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})}, SpreadsheetApp:{flush(){},openById:id=>{if(!libros[id])throw new Error('No encontrado');return libros[id]}, - create:n=>{const L=libro('S'+(++nId),n,{'Hoja 1':hoja([])});archivos[L.id]={id:L.id,padre:miUnidad};return L}, + create:n=>{const L=libro('S'+(++nId),n,{'Hoja 1':hoja([])});archivos[L.id]=compartible({id:L.id,padre:miUnidad});return L}, newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}}, DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]}, createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad, getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}}, - moveTo:f=>{archivos[id].padre=f}})}, + moveTo:f=>{archivos[id].padre=f},getSharingAccess:()=>archivos[id].getSharingAccess(), + setSharing:(a,p)=>archivos[id].setSharing(a,p),getId:()=>id}), + Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}}, }; vm.createContext(ctx); vm.runInContext(src,ctx); const post=(quien,funcion,...args)=>JSON.parse(ctx.doPost({postData:{contents:JSON.stringify({funcion,args,token:quien})}}).t); @@ -169,6 +173,8 @@ assert.equal(ruta(carpCob),'Mi unidad/Cotizador Helgen — Cobranza'); const LC=libros[props.COBRANZA_DB_ID]; assert.equal(LC.nombre,'Cotizador Helgen — Cobranza (datos)'); assert.equal(archivos[LC.id].padre,carpCob); assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','Contratos','Esquemas','Pagos']); +// Restringidos aunque Workspace comparta lo nuevo con todo el dominio +assert.equal(carpCob.acceso,'PRIVATE','carpeta de cobranza restringida'); assert.equal(archivos[LC.id].acceso,'PRIVATE','libro restringido'); const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1); let v=ok('tania','getCobranzaProyecto',p1.toLowerCase()); @@ -264,6 +270,7 @@ const comp=archivos[fp[14]]; assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p1); assert.equal(comp.blob.name,r.folio+' — Transferencia SPEI 123.pdf'); assert.equal(comp.blob.mime,'application/pdf'); assert.deepEqual(Buffer.from(comp.blob.bytes),PDF); assert.equal(fp[15],r.url); +assert.deepEqual([comp.acceso,comp.padre.acceso,comp.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE'],'comprobante y subcarpetas restringidos'); assert(!Object.values(carpetas).some(f=>f.nombre==='Pagos')&&vivos(maestra).every(f=>!/Cobranza/.test(f.nombre)),'nada de cobranza en la carpeta maestra'); function vivos(f){return f.hijos.filter(h=>!h.trashed)} // Idempotencia: el mismo envío devuelve el mismo folio sin duplicar @@ -316,6 +323,9 @@ assert.deepEqual([fu[4],fu[5],fu[6],fu[7],fu[8]],[18500,'MXN',18.5,1000,'USD']); assert.deepEqual([r.vista.resumen.moneda,r.vista.resumen.pagado,r.vista.resumen.saldo],['USD',1000,0]); falla('efrain','registrarPago',[pago({proyectoFolio:p2,monto:1.01,monedaPago:'USD'})],/excede el saldo/); assert.equal(ruta(archivos[fu[14]].padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p2); +// Si alguien vuelve a compartir carpeta o libro con el dominio, prepararCobranza() (y el siguiente pago) los restringe +carpCob.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCobranza(); +assert.deepEqual([carpCob.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCobranza repara el acceso'); // Los datos de cobranza nunca van en la Base de datos (todo el dominio la puede leer) assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos']); assert(!JSON.stringify(db.pest).includes('PAG-'));