@nyx5/nyx5 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,8 +13,11 @@
13
13
 
14
14
  import { sha256hex, canonical, uuid } from '../nucleo/crypto.js';
15
15
  import { parseAddress } from '../correo/resolver.js';
16
+ import { applyInboxPolicy } from '../correo/politica.js';
16
17
 
17
18
  import { LibroError } from './errores.js';
19
+ import { estadoDeCuenta } from './estado.js';
20
+ import { notarize } from './notaria.js';
18
21
 
19
22
  const iso = () => new Date().toISOString();
20
23
  const fail = (code, msg) => { throw new LibroError(code, msg); };
@@ -33,6 +36,36 @@ function scopeCap(ctx, amount, what) {
33
36
  if (cap != null && amount > cap) fail(403, `${what}: ${amount} tok exceeds the subagent cap of ${cap}`);
34
37
  }
35
38
  function must(cond, code, msg) { if (!cond) fail(code, msg); }
39
+
40
+ // ---------- alcance de un mandato: vocabulario CERRADO, y se falla cerrado ----------
41
+ // Un mandato es autoridad de gasto. Si el mandante escribe una restricción que el Libro no sabe
42
+ // aplicar, la opción segura NO es guardarla y seguir: es negarse.
43
+ //
44
+ // Nació de un defecto real (9-sep-2026): un mandato con `max_per_charge: 500` y una lista de
45
+ // destinatarios permitidos guardó las dos, las FIRMÓ, y después dejó pasar un cobro único de
46
+ // 90.000. La restricción se veía al leer el mandato de vuelta y no hacía nada. Un instrumento que
47
+ // falla hacia el ruido es peor que uno que no existe: te deja construir encima.
48
+ //
49
+ // Esto contradice a propósito el invariante 7 (los campos desconocidos se conservan y se ignoran).
50
+ // Ese invariante es para MENSAJES, donde ignorar lo que no entiendes es lo que permite extender el
51
+ // protocolo sin romperlo. Para autoridad de gasto es lo contrario: ignorar una restricción
52
+ // autoriza MÁS de lo que el mandante quiso.
53
+ export const ALCANCE_MANDATO = Object.freeze(['concepts', 'max_per_charge']);
54
+
55
+ function validarAlcance(scope, cap) {
56
+ if (scope == null) return {};
57
+ must(typeof scope === 'object' && !Array.isArray(scope), 400, 'scope must be an object');
58
+ const ajenas = Object.keys(scope).filter((k) => !ALCANCE_MANDATO.includes(k));
59
+ must(!ajenas.length, 400, `this ledger cannot enforce ${ajenas.map((k) => JSON.stringify(k)).join(', ')} in a mandate scope, so it refuses to store it as if it could. It enforces: ${ALCANCE_MANDATO.join(', ')}`);
60
+ if (scope.concepts != null) {
61
+ must(Array.isArray(scope.concepts) && scope.concepts.every((c) => typeof c === 'string'), 400, 'scope.concepts must be a list of strings');
62
+ }
63
+ if (scope.max_per_charge != null) {
64
+ must(Number.isInteger(scope.max_per_charge) && scope.max_per_charge > 0, 400, 'scope.max_per_charge must be a positive integer');
65
+ must(cap == null || scope.max_per_charge <= cap, 400, `scope.max_per_charge (${scope.max_per_charge}) cannot exceed the mandate cap (${cap})`);
66
+ }
67
+ return scope;
68
+ }
36
69
  const parties = (c) => [c.seller, c.buyer, c.verifier, c.arbiter].filter(Boolean);
37
70
 
38
71
  // ============ Operaciones ============
@@ -48,7 +81,7 @@ const ops = {
48
81
  const c = {
49
82
  id: uuid(), kind: q.contract, house: libro.domain, seller: q.seller, buyer: from, amount: q.price,
50
83
  concept: q.concept, terms: q.terms || {}, arbiter: q.arbiter || null, referrer: q.referrer || null,
51
- quote_id: q.id, quote_sha256: sha256hex(canonical(q)), accept_sha256: ctx.opHash,
84
+ quote_id: q.id, quote_sha256: sha256hex(canonical(q)), accept_sha256: ctx.opHash, ...(q.service ? { service: q.service } : {}),
52
85
  state: 'accepted', created: iso(), history: [],
53
86
  };
54
87
  const refs = { contract: c.id, quote: q.id, quote_sha256: c.quote_sha256, op: ctx.env.id, op_sha256: ctx.opHash };
@@ -112,6 +145,43 @@ const ops = {
112
145
  return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
113
146
  },
114
147
 
148
+ // --- escrow que vence (NX-503): el comprador recupera; una entrega sin objeción se libera ---
149
+ // Antes un contrato vencido sólo mandaba un aviso y la plata quedaba retenida para siempre.
150
+ // reclaim: sólo el comprador, sólo sin entrega, y sólo pasado el plazo más la gracia de la casa.
151
+ async reclaim(ctx) {
152
+ const { libro, from, body } = ctx;
153
+ const c = await getContract(libro, body.contract);
154
+ must(c.kind === 'escrow', 409, 'reclaim only applies to escrow');
155
+ must(from === c.buyer, 403, 'only the buyer reclaims an expired escrow');
156
+ must(c.state === 'held', 409, c.state === 'delivered' ? 'the seller already delivered: refund needs the seller or the arbiter, or the review window' : `state ${c.state}`);
157
+ const plazo = Date.parse(c.terms?.deadline || '');
158
+ must(!Number.isNaN(plazo), 409, 'this escrow has no deadline: only the seller or the arbiter can refund it');
159
+ const desde = plazo + libro.reclaimGraceMs;
160
+ must(Date.now() >= desde, 409, `the deadline plus the grace period has not passed yet (reclaimable from ${new Date(desde).toISOString()})`);
161
+ const asiento = await libro.refund(c.id, c.buyer, c.amount, `escrow vencido ${c.id}: ${c.concept}`, { contract: c.id, kind: 'reclaim' }, { op: ctx.env.id, op_sha256: ctx.opHash });
162
+ c.state = 'refunded';
163
+ record(libro, c, 'reclaim', from, { asiento: asiento.id, deadline: c.terms.deadline });
164
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
165
+ },
166
+ // expire: lo firma la CASA (libro@) desde el reloj, nunca una parte. Entregado, con plazo vencido,
167
+ // y pasada la ventana de revisión sin que nadie devolviera ni liberara: se libera al vendedor.
168
+ // «Sin disputa» quiere decir que ni el comprador ni el árbitro hicieron refund en la ventana.
169
+ async expire(ctx) {
170
+ const { libro, from, body } = ctx;
171
+ must(from === libro.address, 403, 'only the house settles an expired escrow');
172
+ const c = await getContract(libro, body.contract);
173
+ must(c.kind === 'escrow' && c.state === 'delivered', 409, `state ${c.state}, expected a delivered escrow`);
174
+ const plazo = Date.parse(c.terms?.deadline || '');
175
+ must(!Number.isNaN(plazo), 409, 'no deadline: a delivery without a deadline waits for the buyer or the arbiter');
176
+ const entregado = Date.parse(c.history.findLast?.((h) => h.op === 'deliver')?.at || c.history.filter((h) => h.op === 'deliver').at(-1)?.at || '');
177
+ const desde = Math.max(plazo, entregado || 0) + libro.reviewWindowMs;
178
+ must(Date.now() >= desde, 409, `the review window is still open (until ${new Date(desde).toISOString()})`);
179
+ const asiento = await libro.release(c.id, c.seller, c.amount, `liberación por ventana vencida ${c.id}: ${c.concept}`, { contract: c.id, kind: 'expire' }, { op: ctx.env.id, op_sha256: ctx.opHash }, c.referrer);
180
+ c.state = 'released';
181
+ record(libro, c, 'expire', from, { asiento: asiento.id, review_until: new Date(desde).toISOString() });
182
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento, note: 'delivered, deadline passed, not refunded within the review window: released by the house clock' } }] };
183
+ },
184
+
115
185
  // --- fianza: el que afirma deposita; si la verificación lo derriba, la pierde ---
116
186
  async bond(ctx) {
117
187
  const { libro, from, body } = ctx;
@@ -161,7 +231,7 @@ const ops = {
161
231
  must(body.cap <= parent.cap - parent.spent, 403, `the sub-mandate (${body.cap}) exceeds what the parent has left (${parent.cap - parent.spent})`);
162
232
  if (parent.expires) must(!body.expires || Date.parse(body.expires) <= Date.parse(parent.expires), 403, 'the sub-mandate cannot outlast its parent');
163
233
  }
164
- const m = { id: uuid(), house: libro.domain, grantor: from, grantee: body.grantee, cap: body.cap, spent: 0, scope: body.scope || {}, expires: body.expires || parent?.expires || null,
234
+ const m = { id: uuid(), house: libro.domain, grantor: from, grantee: body.grantee, cap: body.cap, spent: 0, scope: validarAlcance(body.scope, body.cap), expires: body.expires || parent?.expires || null,
165
235
  parent: parent?.id || null, root: parent ? parent.root : from, chain: [], state: 'active', created: iso(), op_sha256: ctx.opHash };
166
236
  m.chain = parent ? [...parent.chain, m.id] : [m.id];
167
237
  libro.putMandate(m);
@@ -180,7 +250,14 @@ const ops = {
180
250
  must(cur.state === 'active', 409, `mandate ${cur.id} is not active`);
181
251
  must(!cur.expires || Date.parse(cur.expires) > Date.now(), 410, `mandato ${cur.id} vencido`);
182
252
  must(cur.cap - cur.spent >= body.amount, 402, `mandate ${cur.id}: ${cur.cap - cur.spent} left, ${body.amount} requested`);
253
+ // Fallar cerrado: un mandato guardado ANTES de este candado puede llevar una restricción que
254
+ // no sabemos aplicar. No se cobra contra él hasta que su mandante lo rehaga.
255
+ const ajenas = Object.keys(cur.scope || {}).filter((k) => !ALCANCE_MANDATO.includes(k));
256
+ must(!ajenas.length, 403, `mandate ${cur.id} carries ${ajenas.map((k) => JSON.stringify(k)).join(', ')} in its scope, which this ledger cannot enforce; it refuses to charge against a limit it cannot apply`);
183
257
  if (cur.scope?.concepts?.length) must(cur.scope.concepts.includes(body.concept), 403, `concept "${body.concept}" is outside the mandate scope`);
258
+ // Tope por cobro: el total ya no se puede vaciar de un solo golpe. Se comprueba en CADA
259
+ // eslabón, así que el tope del padre acota lo que cobra el nieto.
260
+ if (cur.scope?.max_per_charge != null) must(body.amount <= cur.scope.max_per_charge, 403, `mandate ${cur.id} caps a single charge at ${cur.scope.max_per_charge}, and ${body.amount} was requested`);
184
261
  chain.push(cur);
185
262
  }
186
263
  // El descuento de la cadena y el asiento van en la MISMA transacción: si el commit falla,
@@ -202,22 +279,54 @@ const ops = {
202
279
  return { result: { revoked: affected.map((x) => x.id) }, recibos: [{ to: [...new Set(affected.flatMap((x) => [x.grantor, x.grantee]))], thread: m.chain[0], body: { revoked: affected.map((x) => x.id), by: from } }] };
203
280
  },
204
281
 
282
+ // --- pagar directo: sin cotización ni contrato. Lo firma el que paga; el que recibe no hace nada.
283
+ // Es el "te mando plata" entre dos personas. Sólo dentro de la casa: aquí no hay cómo comprobar
284
+ // que una dirección de otra casa existe, y un pago a nadie deja tokens varados para siempre.
285
+ async pay(ctx) {
286
+ const { libro, from, body } = ctx;
287
+ let to; try { to = parseAddress(body.to); } catch { fail(400, 'pay needs "to": the address that receives'); }
288
+ const address = `${to.local}@${to.domain}`;
289
+ must(to.domain === libro.domain, 400, `pay moves tokens inside ${libro.domain}; ${to.domain} keeps its own ledger`);
290
+ must(address !== from, 400, 'paying yourself moves nothing');
291
+ must(Number.isSafeInteger(body.amount) && body.amount > 0, 400, 'amount must be a positive integer');
292
+ scopeCap(ctx, body.amount, 'paying');
293
+ const rec = await libro.store.getAgent(to.local);
294
+ must(rec && !rec.revoked, 404, `${address} does not exist in this house`);
295
+ // Un subagente de sólo mensajes nunca podrá gastar: lo que le llegue quedaría varado. Se le paga al dueño.
296
+ must(!rec.delegation?.scope?.messages_only, 400, `${address} only carries messages and could never spend this; pay its owner, ${rec.delegation?.by}`);
297
+ const concept = typeof body.concept === 'string' && body.concept.trim() ? body.concept.trim().slice(0, 200) : `pago de ${from}`;
298
+ // Sin fee (decidido por Nicholas el 11-sep-2026): mandarle tokens a una persona es gratis, como un
299
+ // mensaje. El fee de la casa (0,5%) es para el trabajo que alguien encarga: spot, escrow, mandatos.
300
+ const asiento = await libro.post(concept, [{ account: from, delta: -body.amount }, { account: address, delta: body.amount }], { kind: 'pay', to: address, fee: 0 }, { op: ctx.env.id, op_sha256: ctx.opHash });
301
+ // El aviso al que recibe obedece a su buzón, como si el pagador le escribiera. Defecto real
302
+ // (revisión del 11-sep-2026): con pay sin fee, un pago de 1 token dejaba un sobre de libro@ en un
303
+ // buzón que cobra 500 por mensaje. El pago ocurre igual y el saldo lo muestra; el aviso no pasa.
304
+ const p = applyInboxPolicy({ from, to: [address], type: 'receipt' }, rec, null);
305
+ const avisar = p.ok === true && (!p.stamp || body.amount >= p.stamp.price);
306
+ return { result: { asiento, notified: avisar }, recibos: [{ to: avisar ? [from, address] : [from], body: { asiento, pay: { from, to: address, amount: body.amount, concept } } }] };
307
+ },
308
+
205
309
  // --- lecturas: la respuesta vuelve por correo como recibo ---
206
310
  async balance(ctx) {
207
311
  const acc = await ctx.libro.account(ctx.from);
208
312
  return { result: acc, recibos: [{ to: [ctx.from], body: { account: acc.account, balance: acc.balance, contracts: acc.contracts.length, mandates: acc.mandates.length } }] };
209
313
  },
314
+ // Estado de cuenta (NX-501): `since`/`until` ISO-8601 acotan el rango, `limit` cuántos asientos
315
+ // (tope 200 por correo: el recibo viaja en un sobre). Devuelve saldo inicial, filas (fee y
316
+ // comisión como filas propias), saldo final y totales que cuadran. Ver estado.js.
210
317
  async statement(ctx) {
211
- const raw = Number(ctx.body.limit);
212
- const n = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 20;
213
- const entries = await ctx.libro.store.libroStatement(ctx.from, n);
214
- return { result: { entries }, recibos: [{ to: [ctx.from], body: { entries } }] };
318
+ const { since, until, limit } = ctx.body;
319
+ const estado = await estadoDeCuenta(ctx.libro, ctx.from, { since, until, limit, max: 200 });
320
+ return { result: estado, recibos: [{ to: [ctx.from], body: estado }] };
215
321
  },
216
322
  async contract(ctx) {
217
323
  const c = await getContract(ctx.libro, ctx.body.contract);
218
324
  must(parties(c).includes(ctx.from), 403, 'you are not a party to this contract');
219
325
  return { result: { contract: c }, recibos: [{ to: [ctx.from], thread: c.id, body: { contract: c } }] };
220
326
  },
327
+
328
+ // --- notaría (NX-601): sellar el hash de un documento con fecha y firma de la casa. Sin dinero.
329
+ notarize,
221
330
  };
222
331
 
223
332
  // ============ Tipos de contrato cotizables ============
@@ -235,7 +344,7 @@ export const CONTRATOS = {
235
344
  } },
236
345
  // Medido: la aceptación crea un mandato del comprador al vendedor con tope = precio cotizado.
237
346
  metered: { quoteable: true, async onAccept({ libro, c, q, refs }) {
238
- const m = { id: uuid(), house: libro.domain, grantor: c.buyer, grantee: c.seller, cap: c.amount, spent: 0, scope: q.terms?.scope || {}, expires: q.terms?.expires || q.expires || null,
347
+ const m = { id: uuid(), house: libro.domain, grantor: c.buyer, grantee: c.seller, cap: c.amount, spent: 0, scope: validarAlcance(q.terms?.scope, c.amount), expires: q.terms?.expires || q.expires || null,
239
348
  parent: null, root: c.buyer, chain: [], state: 'active', created: iso(), contract: c.id, op_sha256: refs.op_sha256 };
240
349
  m.chain = [m.id];
241
350
  libro.putMandate(m);
@@ -0,0 +1,150 @@
1
+ // Nyx5/1 — Boleta del asiento y estado de cuenta (NX-501).
2
+ //
3
+ // Dos lecturas del mismo diario, sin tocar el kernel:
4
+ // boletaDe(asiento) -> qué parte del movimiento fue fee de la casa y qué parte comisión de
5
+ // referido, LEÍDO de las líneas del asiento. No se recalcula: si el
6
+ // asiento y una tasa discreparan, gana la línea, que es lo que movió.
7
+ // estadoDeCuenta(...) -> el extracto de una cuenta en un rango de fechas: saldo inicial,
8
+ // movimientos, saldo final y totales que cuadran. JSON o CSV (RFC 4180).
9
+ //
10
+ // Modelo de las filas: el fee y la comisión son filas PROPIAS, atribuidas a quien recibe el monto
11
+ // bruto. El fee sale de lo que recibe el vendedor (el comprador paga el precio, ni más ni menos).
12
+ // Para el vendedor de un spot de 200 con fee 10 % y comisión 15 %:
13
+ // in 200 (bruto, contraparte el comprador) · out 20 fee 20 (casa@) · out 30 (referidor)
14
+ // y siempre opening + Σin − Σout = closing. `fee` es un subconjunto de `out`: marca qué salida
15
+ // fue para la casa. Las filas de un mismo asiento comparten `entry`; el saldo tras la última
16
+ // fila del asiento es el del Libro (los intermedios son de presentación).
17
+ //
18
+ // Qué NO cubre, dicho antes de que alguien lo suponga:
19
+ // - Movimientos donde la cuenta no aparece en una línea. Una fianza ejecutada mueve
20
+ // escrow → beneficiario: el afianzado salió del diario al depositarla.
21
+ // - El fee de una estampilla no pasa por un recibo del Libro (viaja en la respuesta x402 como
22
+ // `transaction`); sí aparece en el estado de cuenta del que la cobró.
23
+ // - Si la cuenta es a la vez referidor y pagador (net negativo), no se desglosa: una sola fila.
24
+
25
+ import { LibroError } from './errores.js';
26
+
27
+ export const CSV_CABECERA = Object.freeze(['date', 'entry', 'concept', 'counterparty', 'in', 'out', 'fee', 'balance']);
28
+
29
+ // Fee y comisión de un asiento, leídos de sus líneas. `feeBps` (tasa de la casa) y `share` (bps
30
+ // del referido) son contexto para que la boleta diga la tasa; el MONTO es siempre el de la línea.
31
+ // `meta.fee > 0` distingue un fee de otra entrada positiva de la casa (una fianza ejecutada a su
32
+ // favor no es fee). Nunca lanza: corre dentro de Libro.handle después de que la op ya decidió.
33
+ export function boletaDe(asiento, { casa, feeBps = null, share = null } = {}) {
34
+ if (!asiento || !Array.isArray(asiento.lines)) return {};
35
+ const meta = asiento.meta || {};
36
+ const out = {};
37
+ const fee = meta.fee > 0 ? asiento.lines.find((l) => l.account === casa && l.delta > 0) : null;
38
+ if (fee) out.fee = { account: casa, amount: fee.delta, ...(feeBps != null ? { bps: feeBps } : {}) };
39
+ const com = meta.commission > 0 && meta.referrer ? asiento.lines.find((l) => l.account === meta.referrer && l.delta > 0) : null;
40
+ if (com) out.commission = { account: meta.referrer, amount: com.delta, ...(share != null ? { bps: share } : {}) };
41
+ return out;
42
+ }
43
+
44
+ // Las filas de UNA cuenta en UN asiento. [] si la cuenta no aparece en ninguna línea.
45
+ export function filasDe(asiento, account, casa) {
46
+ const mias = asiento.lines.filter((l) => l.account === account);
47
+ if (!mias.length) return [];
48
+ const propio = mias.reduce((s, l) => s + l.delta, 0);
49
+ const meta = asiento.meta || {};
50
+ const { fee, commission } = boletaDe(asiento, { casa });
51
+ // Contraparte: las líneas del signo contrario que no son el fee ni la comisión (salvo que la
52
+ // cuenta sea justamente la casa o el referidor: entonces esas líneas son las suyas).
53
+ const excluidas = new Set([account, ...(fee ? [casa] : []), ...(commission ? [meta.referrer] : [])]);
54
+ const contra = asiento.lines.filter((l) => !excluidas.has(l.account) && Math.sign(l.delta) === -Math.sign(propio)).map((l) => l.account);
55
+ const base = { date: asiento.at, entry: asiento.id, n: asiento.n, concept: asiento.concept, counterparty: contra.join(';') };
56
+ // Quien recibe el bruto: net positivo, no es la casa ni el referidor, y el asiento repartió.
57
+ const bruto = propio > 0 && account !== casa && account !== meta.referrer && (fee || commission);
58
+ if (!bruto) return [{ ...base, kind: 'movement', in: propio > 0 ? propio : 0, out: propio < 0 ? -propio : 0, fee: 0 }];
59
+ const filas = [{ ...base, kind: 'movement', in: propio + (fee?.amount || 0) + (commission?.amount || 0), out: 0, fee: 0 }];
60
+ if (fee) filas.push({ ...base, kind: 'fee', concept: 'house fee', counterparty: casa, in: 0, out: fee.amount, fee: fee.amount });
61
+ if (commission) filas.push({ ...base, kind: 'commission', concept: 'referral commission', counterparty: meta.referrer, in: 0, out: commission.amount, fee: 0 });
62
+ return filas;
63
+ }
64
+
65
+ // Fecha ISO normalizada al formato exacto de `asiento.at` (toISOString), para que la comparación
66
+ // lexicográfica en el almacén sea la misma que la numérica. Una fecha sola ("2026-09-13") es la
67
+ // medianoche UTC de ese día: como `until` es exclusivo, `hasta=2026-09-14` cubre el 13 entero.
68
+ function fechaIso(v, nombre) {
69
+ if (v == null || v === '') return null;
70
+ // Sin zona horaria, Node la interpreta en la del proceso y workerd en UTC: se exige Z u offset
71
+ // (una fecha sola, sin hora, es UTC en las dos).
72
+ const t = typeof v === 'string' && (/^\d{4}-\d{2}-\d{2}$/.test(v) || /(Z|[+-]\d{2}:?\d{2})$/.test(v)) ? Date.parse(v) : NaN;
73
+ if (Number.isNaN(t)) throw new LibroError(400, `${nombre} must be an ISO-8601 date, got ${JSON.stringify(v)}`);
74
+ return new Date(t).toISOString();
75
+ }
76
+
77
+ // El estado de cuenta de `account` en [since, until). `limit` acota cuántos asientos se listan
78
+ // (se conservan los MÁS RECIENTES, como el statement de siempre); `max` es el techo que pone el
79
+ // llamador (200 por correo, 1000 por HTTP). Sin rango, el límite por defecto sigue siendo 20.
80
+ //
81
+ // opening_balance es el saldo justo antes del primer asiento listado (sumado de sus líneas, no
82
+ // leído de saldos), así que cuadra también cuando la lista está truncada. `ledger_balance` es lo
83
+ // que dice el estado de saldos hoy: cuando el extracto llega hasta ahora, los dos tienen que
84
+ // coincidir, y `reconciled` los resta. Si un día no coinciden, el diario y los saldos divergieron
85
+ // y esto es lo primero que lo dice.
86
+ export async function estadoDeCuenta(libro, account, { since = null, until = null, limit = null, max = 200 } = {}) {
87
+ const desde = fechaIso(since, 'since'), hasta = fechaIso(until, 'until');
88
+ if (desde && hasta && desde > hasta) throw new LibroError(400, `since (${desde}) is after until (${hasta})`);
89
+ const raw = Number(limit);
90
+ const n = Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : (desde || hasta ? max : 20);
91
+ const store = libro.store, casa = libro.casa;
92
+ const ledger = await libro.balance(account);
93
+ const { entries, total } = await store.libroStatementRange(account, { since: desde, until: hasta, limit: n });
94
+ const opening = entries.length
95
+ ? await store.libroBalanceBefore(account, { n: entries[0].n })
96
+ : (desde ? await store.libroBalanceBefore(account, { at: desde }) : 0);
97
+ const totals = { in: 0, out: 0, fees: 0, commissions: 0 };
98
+ const rows = [];
99
+ let saldo = opening;
100
+ for (const a of entries) {
101
+ for (const r of filasDe(a, account, casa)) {
102
+ saldo += r.in - r.out;
103
+ totals.in += r.in; totals.out += r.out; totals.fees += r.fee;
104
+ if (r.kind === 'commission') totals.commissions += r.out;
105
+ rows.push({ ...r, balance: saldo });
106
+ }
107
+ }
108
+ // Cuadre interno: lo que dicen las filas tiene que ser lo que dicen las líneas del diario.
109
+ const neto = entries.reduce((s, a) => s + a.lines.filter((l) => l.account === account).reduce((t, l) => t + l.delta, 0), 0);
110
+ if (opening + neto !== saldo) throw new LibroError(500, `statement does not reconcile with the journal (${opening} + ${neto} != ${saldo})`);
111
+ // Sin `until` la lista termina en el último asiento de la cuenta (se conservan los más
112
+ // recientes): el saldo final tiene que ser el saldo de hoy, esté o no truncada la lista.
113
+ const reconciled = hasta ? null : saldo === ledger;
114
+ return {
115
+ account, house: libro.domain, since: desde, until: hasta,
116
+ opening_balance: opening, entries: rows, closing_balance: saldo, totals,
117
+ rows: rows.length, entries_shown: entries.length, entries_total: total, truncated: total > entries.length,
118
+ ledger_balance: ledger, reconciled,
119
+ };
120
+ }
121
+
122
+ // ---------- CSV (RFC 4180) ----------
123
+ // Un campo con coma, comillas o salto de línea va entre comillas, y las comillas se doblan.
124
+ export function campoCsv(v) {
125
+ const s = v == null ? '' : String(v);
126
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
127
+ }
128
+ // Texto que escribió otro agente (concepto, contraparte) y que una hoja de cálculo podría tomar
129
+ // por fórmula: se antepone un apóstrofo, como recomienda OWASP. Los números son nuestros.
130
+ function textoCsv(s) {
131
+ const t = s == null ? '' : String(s);
132
+ return /^[=+\-@\t\r]/.test(t) ? `'${t}` : t;
133
+ }
134
+ export function csvDe(estado) {
135
+ const num = (x) => (x ? String(x) : '');
136
+ const lineas = [CSV_CABECERA.join(',')];
137
+ for (const r of estado.entries) {
138
+ lineas.push([r.date, r.entry, textoCsv(r.concept), textoCsv(r.counterparty), num(r.in), num(r.out), num(r.fee), String(r.balance)].map(campoCsv).join(','));
139
+ }
140
+ return `${lineas.join('\r\n')}\r\n`;
141
+ }
142
+ // estado-<local>-<desde>-<hasta>.csv; sin rango: inicio / la fecha de hoy. Sólo caracteres seguros
143
+ // para un nombre de archivo en una cabecera.
144
+ export function nombreCsv(estado, hoy = new Date()) {
145
+ const [local, domain] = String(estado.account).split('@');
146
+ const quien = domain && domain !== estado.house ? `${local}.${domain}` : local;
147
+ const desde = estado.since ? estado.since.slice(0, 10) : 'inicio';
148
+ const hasta = estado.until ? estado.until.slice(0, 10) : hoy.toISOString().slice(0, 10);
149
+ return `estado-${quien}-${desde}-${hasta}.csv`.replace(/[^A-Za-z0-9._-]/g, '-');
150
+ }
@@ -51,6 +51,119 @@ export async function datosInforme(estafeta, { dias = 7 } = {}) {
51
51
  };
52
52
  }
53
53
 
54
+ // ---------- Embudo (NX-801): de punta a punta, por fuente y por semana ----------
55
+ // Seis etapas, en el orden en que una persona las cruza. Cada una es UN evento del diario; el
56
+ // informe no infiere ninguna. `open_invite` cuenta visitas al enlace (una vista previa de
57
+ // WhatsApp también abre); las otras cinco cuentan direcciones raíz, una vez cada una.
58
+ export const ETAPAS = [
59
+ { id: 'open_invite', label: 'Invite opened' },
60
+ { id: 'join', label: 'Address created' },
61
+ { id: 'claude_connected', label: 'Claude connected' },
62
+ { id: 'first_message', label: 'First message sent' },
63
+ { id: 'first_quote', label: 'First contract' },
64
+ { id: 'mandate_created', label: 'First mandate' },
65
+ ];
66
+ // `connector_authorized` existía antes de NX-801 y se emite en el mismo punto que
67
+ // `claude_connected`: leerlo como alias hace que los recorridos anteriores al despliegue (Basti,
68
+ // 11-sep) muestren la etapa 3 en vez de un hueco. Las etapas 1 y 4 no tienen alias: antes no se
69
+ // medían, y el informe las muestra vacías en vez de inventarlas.
70
+ const ALIAS = { connector_authorized: 'claude_connected' };
71
+
72
+ // Semana ISO-8601 (lunes a domingo, la semana 1 es la del primer jueves), en UTC como el diario.
73
+ export function semanaIso(ts) {
74
+ const d = new Date(ts);
75
+ if (Number.isNaN(d.getTime())) return null;
76
+ d.setUTCHours(0, 0, 0, 0);
77
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
78
+ const primero = Date.UTC(d.getUTCFullYear(), 0, 1);
79
+ return `${d.getUTCFullYear()}-W${String(Math.ceil(((d - primero) / 86_400_000 + 1) / 7)).padStart(2, '0')}`;
80
+ }
81
+
82
+ // Qué lee: los `join` de siempre (son la lista de raíces y de fuentes) y todos los eventos de la
83
+ // ventana. Qué NO cubre: un delegado cuyo nombre no termine en `.<raíz>` de una raíz conocida queda
84
+ // en `sinRaiz`, contado y no atribuido; si el diario supera el tope, `truncado` lo dice.
85
+ export async function datosEmbudo(estafeta, { ventanaDias = 90, semanas = 8, diasRecorridos = 30, tope = 5000 } = {}) {
86
+ const ahora = Date.now();
87
+ const desde = new Date(ahora - ventanaDias * 86_400_000).toISOString();
88
+ const lee = async (q) => (await estafeta.store.listEvents?.(q)) || [];
89
+ const [eventos, joins] = await Promise.all([lee({ since: desde, limit: tope }), lee({ name: 'join', limit: tope })]);
90
+
91
+ // Raíces conocidas: quien hizo join, y quien las etapas 3 y 4 nombran (por construcción, raíces).
92
+ const raices = new Set(joins.map((e) => e.actor).filter(Boolean));
93
+ for (const e of eventos) if (['claude_connected', 'connector_authorized', 'first_message'].includes(e.name) && e.actor) raices.add(e.actor);
94
+ // Un delegado se llama <nombre>.<raíz>@casa: se prueba cada sufijo hasta dar con una raíz conocida.
95
+ const raizDe = (dir) => {
96
+ if (typeof dir !== 'string' || !dir.includes('@')) return null;
97
+ if (raices.has(dir)) return dir;
98
+ const [local, dominio] = dir.split('@');
99
+ const partes = local.split('.');
100
+ for (let i = 1; i < partes.length; i++) { const c = `${partes.slice(i).join('.')}@${dominio}`; if (raices.has(c)) return c; }
101
+ return null;
102
+ };
103
+ const recorridos = new Map();
104
+ const alcanza = (raiz, etapa, ts) => {
105
+ const r = recorridos.get(raiz) || { address: raiz, source: null, code: null, etapas: {} };
106
+ if (!r.etapas[etapa] || ts < r.etapas[etapa]) r.etapas[etapa] = ts;
107
+ recorridos.set(raiz, r);
108
+ return r;
109
+ };
110
+ const sinRaiz = {};
111
+ const aperturas = [];
112
+ for (const e of joins) { const r = alcanza(e.actor, 'join', e.ts); r.source = r.source || e.data?.source || null; }
113
+ for (const e of eventos) {
114
+ const etapa = ALIAS[e.name] || e.name;
115
+ if (etapa === 'open_invite') { aperturas.push(e); continue; }
116
+ if (etapa === 'join' || !ETAPAS.some((x) => x.id === etapa)) continue;
117
+ // Un contrato lo alcanzan las dos partes: el actor de `first_quote` es el comprador, `seller` la otra.
118
+ for (const p of etapa === 'first_quote' ? [e.actor, e.data?.seller] : [e.actor]) {
119
+ const raiz = raizDe(p);
120
+ if (!raiz) { if (p) sinRaiz[etapa] = (sinRaiz[etapa] || 0) + 1; continue; }
121
+ const r = alcanza(raiz, etapa, e.ts);
122
+ if (etapa === 'claude_connected' && e.data?.code && !r.code) r.code = e.data.code;
123
+ }
124
+ }
125
+ // Enlace abierto -> dirección: por el código de la invitación que `claude_connected` trae. Si el
126
+ // join no declaró fuente, la del enlace es la atribución (así se atribuye a quien llegó por la app).
127
+ const porCodigo = new Map();
128
+ for (const a of aperturas) { const c = a.data?.code; if (c && (!porCodigo.has(c) || a.ts < porCodigo.get(c).ts)) porCodigo.set(c, a); }
129
+ for (const r of recorridos.values()) {
130
+ const a = r.code && porCodigo.get(r.code);
131
+ if (a) { alcanza(r.address, 'open_invite', a.ts); r.source = r.source || a.data?.source || null; }
132
+ }
133
+ const NO = '(not declared)';
134
+ const inc = (tabla, fila, etapa) => { const f = tabla.get(fila) || {}; f[etapa] = (f[etapa] || 0) + 1; tabla.set(fila, f); };
135
+
136
+ // Por fuente, en la ventana: aperturas por su propia fuente; direcciones cuyo join cae en la
137
+ // ventana (o que no tienen join registrado) por la fuente del recorrido.
138
+ const porFuente = new Map();
139
+ for (const a of aperturas) inc(porFuente, a.data?.source || NO, 'open_invite');
140
+ for (const r of recorridos.values()) {
141
+ if (r.etapas.join && r.etapas.join < desde) continue;
142
+ for (const etapa of Object.keys(r.etapas)) if (etapa !== 'open_invite') inc(porFuente, r.source || NO, etapa);
143
+ }
144
+ // Por semana ISO: las últimas N, la actual incluida. Cada etapa cuenta en la semana en que se alcanzó.
145
+ const semanasLista = [];
146
+ for (let i = semanas - 1; i >= 0; i--) semanasLista.push(semanaIso(ahora - i * 7 * 86_400_000));
147
+ const porSemana = new Map(semanasLista.map((s) => [s, {}]));
148
+ for (const a of aperturas) { const s = semanaIso(a.ts); if (porSemana.has(s)) inc(porSemana, s, 'open_invite'); }
149
+ for (const r of recorridos.values()) for (const [etapa, ts] of Object.entries(r.etapas)) { if (etapa === 'open_invite') continue; const s = semanaIso(ts); if (porSemana.has(s)) inc(porSemana, s, etapa); }
150
+ // Recorridos individuales: los que alcanzaron alguna etapa en los últimos N días, el más reciente primero.
151
+ const corte = new Date(ahora - diasRecorridos * 86_400_000).toISOString();
152
+ const ultima = (r) => Object.values(r.etapas).sort().at(-1) || '';
153
+ const recientes = [...recorridos.values()].filter((r) => ultima(r) >= corte).sort((a, b) => (ultima(a) < ultima(b) ? 1 : -1))
154
+ .map((r) => ({ address: r.address, source: r.source, etapas: Object.fromEntries(ETAPAS.map((x) => [x.id, r.etapas[x.id] || null])) }));
155
+ const fila = (t) => ETAPAS.map((x) => t[x.id] || 0);
156
+ return {
157
+ generado: new Date(ahora).toISOString(), desde, ventanaDias, semanas, diasRecorridos,
158
+ eventosLeidos: eventos.length, joinsLeidos: joins.length, tope, truncado: eventos.length >= tope || joins.length >= tope,
159
+ etapas: ETAPAS.map((x) => x.label),
160
+ porFuente: [...porFuente.entries()].sort((a, b) => (b[1].join || 0) - (a[1].join || 0) || (b[1].open_invite || 0) - (a[1].open_invite || 0)).map(([fuente, t]) => ({ fuente, conteos: fila(t) })),
161
+ porSemana: [...porSemana.entries()].map(([semana, t]) => ({ semana, conteos: fila(t) })),
162
+ recorridos: recientes,
163
+ raices: recorridos.size, sinRaiz,
164
+ };
165
+ }
166
+
54
167
  // Las frases que interpretan los números. Cada una puede ser mala noticia, y esa es la idea.
55
168
  export function lecturas(d) {
56
169
  const L = [];
@@ -67,7 +180,31 @@ export function lecturas(d) {
67
180
  return L;
68
181
  }
69
182
 
70
- export function informeHtml(dominio, d) {
183
+ // La sección privada del embudo: tablas sin JS, cada celda escapada. Sólo se arma cuando la
184
+ // ruta privada la pide; /report nunca la recibe.
185
+ export function embudoHtml(e) {
186
+ const th = (xs) => `<tr>${xs.map((x) => `<th>${esc(x)}</th>`).join('')}</tr>`;
187
+ const tr = (cabeza, xs) => `<tr><td class="k">${esc(cabeza)}</td>${xs.map((x) => `<td class="n">${esc(x)}</td>`).join('')}</tr>`;
188
+ const fecha = (ts) => (ts ? ts.slice(0, 10) : '—');
189
+ const tabla = (cabeza, filas) => `<div class="ancha"><table class="embudo">${th(cabeza)}${filas.join('')}</table></div>`;
190
+ const vacio = (texto) => `<p class="rango">${esc(texto)}</p>`;
191
+ return `
192
+ <h2>Funnel</h2>
193
+ <p class="rango">Private: this section names addresses. Read from the events diary at load time: ${esc(e.eventosLeidos)} events in the last ${esc(e.ventanaDias)} days and ${esc(e.joinsLeidos)} joins overall (limit ${esc(e.tope)} each${e.truncado ? ', REACHED: older events were left out' : ''}). "Invite opened" counts link visits, previews included; the other five count addresses once each. A missing stage is shown as a dash, never guessed.</p>
194
+
195
+ <h3>By source, last ${esc(e.ventanaDias)} days</h3>
196
+ ${e.porFuente.length ? tabla(['Source', ...e.etapas], e.porFuente.map((f) => tr(f.fuente, f.conteos))) : vacio('No joins and no invite opened in this window.')}
197
+
198
+ <h3>By ISO week, last ${esc(e.semanas)}</h3>
199
+ ${tabla(['Week', ...e.etapas], e.porSemana.map((s) => tr(s.semana, s.conteos)))}
200
+
201
+ <h3>Journeys, last ${esc(e.diasRecorridos)} days</h3>
202
+ ${e.recorridos.length ? tabla(['Address', 'Source', ...e.etapas], e.recorridos.map((r) => tr(r.address, [r.source || '(not declared)', ...ETAPAS.map((x) => fecha(r.etapas[x.id]))]))) : vacio('Nobody reached any stage in this window.')}
203
+ ${Object.keys(e.sinRaiz).length ? `<p class="rango">Not attributed to any known root address: ${esc(Object.entries(e.sinRaiz).map(([k, v]) => `${k} ${v}`).join(', '))}.</p>` : ''}
204
+ `;
205
+ }
206
+
207
+ export function informeHtml(dominio, d, { embudo = null } = {}) {
71
208
  const fila = (k, v) => `<tr><td>${esc(k)}</td><td class="n">${esc(v)}</td></tr>`;
72
209
  const fuentes = Object.entries(d.porFuente).sort((a, b) => b[1] - a[1]);
73
210
  return `<!doctype html>
@@ -99,6 +236,13 @@ export function informeHtml(dominio, d) {
99
236
  li b { color:var(--ink); }
100
237
  footer { color:var(--dim); font-size:.82rem; border-top:1px solid var(--line); padding-top:1.4rem; }
101
238
  a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
239
+ h3 { font-size:.95rem; font-weight:600; margin:0 0 .6rem; }
240
+ .ancha { overflow-x:auto; margin:0 0 2rem; }
241
+ table.embudo { font-size:.8rem; margin:0; }
242
+ table.embudo th { text-align:right; font-weight:600; color:var(--dim); padding:.35rem .4rem; border-bottom:1px solid var(--line); white-space:nowrap; }
243
+ table.embudo th:first-child, table.embudo td.k { text-align:left; padding-left:0; }
244
+ table.embudo td { padding:.35rem .4rem; }
245
+ table.embudo td.n { font-size:.8rem; }
102
246
  </style>
103
247
  </head>
104
248
  <body>
@@ -124,9 +268,9 @@ export function informeHtml(dominio, d) {
124
268
 
125
269
  <h2>Where they came from</h2>
126
270
  ${fuentes.length ? `<table>${fuentes.map(([f, n]) => fila(f, n)).join('')}</table><p class="rango">Joins, not visits. A page view is not an agent.</p>` : '<p class="rango">Nobody joined, so there is nothing to attribute.</p>'}
127
-
271
+ ${embudo ? embudoHtml(embudo) : ''}
128
272
  <footer>
129
- No agent names, no counterparties and no message content appear here — only how many and how much.
273
+ ${embudo ? 'This page is private to the house and names addresses; the public <code>/report</code> does not.' : 'No agent names, no counterparties and no message content appear here — only how many and how much.'}
130
274
  Each agent's own record is public and verifiable separately at <code>/agents/&lt;name&gt;/historial</code>.
131
275
  <br><a href="/">Home</a> · <a href="/spec">Specification</a>
132
276
  </footer>