@fullqueso/mcp-bc-gastos 1.25.0 → 1.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.27.0] — 2026-06-05
4
+
5
+ ### Changed
6
+ - **`get_cash_flow` — efecto cambiario revaluado contra el SALDO REAL del banco + asiento sugerido (corrige v1.26.0).** FP detectó que el saldo VES del GL está inflado por **backlog de conciliación** (transacciones que el banco ya procesó pero BC no postea). Ej FQ88: GL cuenta 18250 = 12.44M VES vs saldo real del banco = 1.19M VES. Por eso el `fx_adjustment` de v1.26.0 (sobre GL, −$2,290) **sobrestimaba** la pérdida real. Ahora se revalúa el **saldo real** (`BankAccReconciliation.StatementEndingBalance`, en VES) y se aísla el backlog.
7
+ - **Matemática robusta:** por cuenta, `implied_rate = gl_ves/gl_usd` (tasa promedio histórica), `fx_real = stmt_ves × (1/rate_close − 1/implied_rate)` — escala por el saldo **real**, no el GL inflado. `recon_gap = gl_ves − stmt_ves` se reporta aparte (backlog a postear, NO es FX).
8
+ - **3 buckets de cuentas VES:** (1) banco real con statement → entra al total y al **asiento sugerido**; (2) banco sin statement → provisional, flag, fuera del total; (3) ajuste/tránsito (lista explícita en config: 18275, 18297) → solo informativo. Flag de tasa implícita atípica.
9
+ - **Asiento sugerido de diferencial cambiario** (READ-ONLY texto, cuadra DR=CR): débito Diferencial Cambiario / crédito cada banco VES por su `fx_real`. Es la hoja de trabajo del cierre.
10
+ - Nuevo módulo compartido `tools/financials/fx-cash.js` (computeCashPosition, buildFxJournal, consolidateCashPositions) — reutilizable por get_cash_flow y, a futuro, por el paso de cierre-mensual (Fase 3B). Nuevo `bcClient.getBankStatementBalances` (OData V4 `BankAccReconciliation`, elige la statement ≤ fin de período). Completado el mapeo banco→GL de FQ88 en `config/bank-gl-map.json` (desde fq-239-audit) + lista `adjustment_accounts_ves`.
11
+ - El bloque `cash_position` cambia de forma: `bank_accounts`/`pending_no_statement`/`informational` + `totals` (solo bucket 1) + `fx_journal` + `gl_basis` (referencia v1.26.0). Insight separa "brecha de conciliación" de "efecto cambiario sobre caja real". **El FCF sigue intacto** (el FX es memo de balance).
12
+ - **Verificado contra BC real (FQ88, mayo 2026):** GL libro $34,880 / FX sobre GL −$2,290 (v1.26.0, sobrestimado) → **caja real $5,884 / FX real −$500.89** (correcto); brecha de conciliación 14.36M VES (~$25,897, backlog). Asiento cuadra ($500.89). FCF intacto.
13
+
14
+ ### Pendiente (Fase 3B)
15
+ - `generate_fx_revaluation` en cierre-mensual: emitir el asiento de diferencial cambiario integrado al journal de cierre (reusa el módulo `fx-cash.js`).
16
+
17
+ ---
18
+
19
+ ## [1.26.0] — 2026-06-05
20
+
21
+ ### Added
22
+ - **`get_cash_flow` — posición de caja + efecto cambiario sobre la caja VES (Fase 2 FX).** Los bancos/caja en bolívares reciben depósitos que se dolarizan a la tasa del día, pero el VES se devalúa ~0.75–1%/día y BC **no postea revaluación** → el USD en libros sobrestima la caja real. Nuevo bloque `cash_position` que revalúa el saldo VES a la tasa de fin de período. **No afecta el FCF** (queda 100% operativo; el efecto cambiario es un memo de balance).
23
+ - **Fuente:** `generalLedgerEntries` trae ambas monedas por cuenta — `debitAmount/creditAmount` = USD (libro) y `additionalCurrencyDebitAmount/additionalCurrencyCreditAmount` = VES (saldo físico). Nuevo método `bcClient.getCashGLBalances(companyId, asOfDate)` (acumulado a fecha, cuentas 18xxx, ambas monedas).
24
+ - **Métrica:** por cada cuenta VES, `usd_real = ves_balance / rate_end`; `fx_adjustment = usd_real − usd_book` (< 0 cuando el VES se devaluó). `cash_position` expone `ves {book_usd, real_usd, fx_adjustment, accounts[]}`, `usd {total_usd}`, `total_cash_book_usd`, `total_cash_real_usd`, `rate_end`, `month_devaluation_pct`. Insight automático cuantificando la sobrestimación.
25
+ - **Regla VES (estructural, confirmada por FP):** bancos VES = `18200–18299`; caja VEB = `18110/18130/18160`; resto de 18xxx = USD (sin ajuste, tenencia física en USD). Captura todas las cuentas VES sin mapear banco→GL por cuenta.
26
+ - Nuevo param `include_cash_position` (default `true`). Card HTML "Posición de Caja · Efecto Cambiario (VES)" en single/multi/consolidado. Solo se calcula para el período actual (es saldo a fin de período).
27
+ - **Verificado contra BC real (FQ88, 31-may-2026):** caja VES libro **$34,873** → real **$32,583** (a 554.43 VES/USD) → ajuste **−$2,290** (6.57%); caja total libro $35,310 → real $33,020; devaluación del mes **13.25%**. La regla estructural capturó las 10 cuentas VES (2 más que el sondeo manual: 18275, 18297). FCF intacto en $30,165.08.
28
+
29
+ ---
30
+
3
31
  ## [1.25.0] — 2026-06-05
4
32
 
5
33
  ### Added
@@ -41,18 +41,18 @@
41
41
  },
42
42
  "FQ88": {
43
43
  "store_name": "FQ88 - La Candelaria",
44
- "_nota": "GL accounts pendientes de confirmar con CoA de FQ88",
44
+ "_nota": "GL confirmados 2026-06-05 desde fq-239-audit/data/fq_bancos_all.json (consolidacion_por_banco).",
45
45
  "accounts": {
46
- "MN0001": { "gl": null, "name": "Bancamiga 0240 Bs.", "currency": "VES", "category": "bancos_nacionales" },
47
- "MN0002": { "gl": null, "name": "Bancamiga 9379 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
48
- "MN0003": { "gl": null, "name": "BDV 9127 Bs.", "currency": "VES", "category": "bancos_nacionales" },
49
- "MN0004": { "gl": null, "name": "BDV 7191 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
50
- "MN0005": { "gl": null, "name": "Bancrecer 2558 Bs.", "currency": "VES", "category": "bancos_nacionales" },
51
- "MN0007": { "gl": null, "name": "BDV Pago Movil 5145", "currency": "VES", "category": "bancos_nacionales" },
52
- "MN0028": { "gl": null, "name": "Ubii Bank", "currency": "VES", "category": "bancos_nacionales" },
46
+ "MN0001": { "gl": "18210", "name": "Bancamiga 0240 Bs.", "currency": "VES", "category": "bancos_nacionales" },
47
+ "MN0002": { "gl": "18220", "name": "Bancamiga 9379 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
48
+ "MN0003": { "gl": "18230", "name": "BDV 9127 Bs.", "currency": "VES", "category": "bancos_nacionales" },
49
+ "MN0004": { "gl": "18240", "name": "BDV 7191 Bs. *", "currency": "VES", "category": "bancos_nacionales" },
50
+ "MN0005": { "gl": "18250", "name": "Bancrecer 2558 Bs.", "currency": "VES", "category": "bancos_nacionales" },
51
+ "MN0007": { "gl": "18270", "name": "BDV Pago Movil 5145", "currency": "VES", "category": "bancos_nacionales" },
52
+ "MN0028": { "gl": "18290", "name": "Ubii Bank", "currency": "VES", "category": "bancos_nacionales" },
53
53
  "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
54
54
  "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
55
- "ME0005": { "gl": null, "name": "Zelle USD", "currency": "USD", "category": "bancos_extranjeros" }
55
+ "ME0005": { "gl": "18410", "name": "Zelle USD", "currency": "USD", "category": "bancos_extranjeros" }
56
56
  }
57
57
  }
58
58
  },
@@ -89,5 +89,7 @@
89
89
  "18499": "Total Bancos Extranjeros Divisas",
90
90
  "18998": "Total Bancos",
91
91
  "18999": "Total Caja y Bancos"
92
- }
92
+ },
93
+ "_adjustment_accounts_nota": "Cuentas VES de ajuste/tránsito (NO son bancos reales, no tienen statement). Se tratan como informativas en la revaluación cambiaria (fuera del total de pérdida y del asiento sugerido). FP 2026-06-05.",
94
+ "adjustment_accounts_ves": ["18275", "18297"]
93
95
  }
package/lib/bc-client.js CHANGED
@@ -316,6 +316,51 @@ export class BCClient {
316
316
  return this.apiCallAllPages(url);
317
317
  }
318
318
 
319
+ // Saldos acumulados (a fecha) de cuentas con AMBAS monedas: debit/creditAmount = USD (LCY)
320
+ // y additionalCurrency*Amount = VES (moneda adicional). Para revaluación de caja VES.
321
+ // postingDate le asOfDate (acumulado desde el inicio = saldo a la fecha).
322
+ async getCashGLBalances(companyId, asOfDate, accountMin = '18000', accountMax = '18999', maxPages = 60) {
323
+ const ed = this._sanitizeOData(asOfDate);
324
+ const url = this.buildApiUrl(companyId, 'generalLedgerEntries', {
325
+ $filter: `postingDate le ${ed} and accountNumber ge '${accountMin}' and accountNumber le '${accountMax}'`,
326
+ $select: 'accountNumber,description,debitAmount,creditAmount,additionalCurrencyDebitAmount,additionalCurrencyCreditAmount',
327
+ $orderby: 'postingDate desc',
328
+ });
329
+ return this.apiCallAllPages(url, maxPages);
330
+ }
331
+
332
+ // Saldos REALES de banco (statement) por cuenta, vía BankAccReconciliation (OData V4).
333
+ // Por cada BankAccountNo elige la statement más reciente con StatementDate ≤ asOfDate
334
+ // (los bancos tienen varias statements semanales + cierre de mes). Devuelve un mapa
335
+ // { 'FQ88-MN0005': { ending: 1190029.52, statementDate, statementNo, stale } }.
336
+ async getBankStatementBalances(storeCode, asOfDate) {
337
+ const url = this.buildODataUrl(storeCode, 'BankAccReconciliation', {
338
+ $select: 'BankAccountNo,StatementNo,StatementDate,StatementEndingBalance',
339
+ });
340
+ const rows = await this.odataCallAllPages(url);
341
+ const list = rows.value || rows || [];
342
+ const ad = String(asOfDate);
343
+ const byAcct = {};
344
+ for (const r of list) {
345
+ const acct = r.BankAccountNo;
346
+ if (!acct) continue;
347
+ const date = r.StatementDate ? String(r.StatementDate) : '';
348
+ // Preferir statements ≤ asOfDate (la más reciente). Si no hay, usar la más antigua futura (stale).
349
+ const cur = byAcct[acct];
350
+ const within = date && date <= ad;
351
+ if (!cur) {
352
+ byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: !within };
353
+ } else if (within) {
354
+ // entre las ≤ asOfDate, quedarse con la fecha mayor
355
+ if (cur.stale || date > cur.statementDate) byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: false };
356
+ } else if (cur.stale && date < cur.statementDate) {
357
+ // todas futuras: quedarse con la más cercana (menor fecha futura)
358
+ byAcct[acct] = { ending: r.StatementEndingBalance || 0, statementDate: date, statementNo: r.StatementNo, stale: true };
359
+ }
360
+ }
361
+ return byAcct;
362
+ }
363
+
319
364
  // Fetch revenue entries (40000-49999) - credit balance = income
320
365
  async getRevenueEntries(companyId, startDate, endDate) {
321
366
  return this.getGLEntries(companyId, startDate, endDate, '40000', '49999');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "MCP server for Business Central operational expense analysis, bank reconciliation, POS reconciliation, accounts receivable/payable, multi-payment draft visibility, payroll, inventory cost analysis, and manager reports - Full Queso franchise stores",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -38,6 +38,7 @@ ${renderWaterfall(s.fcf_bridge)}
38
38
  ${renderKpis(s)}
39
39
  ${renderWorkingCapital(s)}
40
40
  ${renderCapexCard(s)}
41
+ ${renderCashPositionCard(s)}
41
42
  ${comparison ? renderComparisonCard({ [code]: comparison }, result) : ''}
42
43
  `;
43
44
  return renderShell(`${s.store_code} · Cash Flow · ${result.period.label}`, header, body, result);
@@ -67,6 +68,7 @@ function renderMulti(result) {
67
68
  ${renderWaterfall(s.fcf_bridge)}
68
69
  ${renderKpis(s)}
69
70
  ${renderWorkingCapital(s)}
71
+ ${renderCashPositionCard(s)}
70
72
  </div>
71
73
  </details>`;
72
74
  })
@@ -78,6 +80,7 @@ ${renderStoresComparison(result)}
78
80
  ${con ? renderWaterfall(con.fcf_bridge) : ''}
79
81
  ${con ? renderKpis(con) : ''}
80
82
  ${con ? renderWorkingCapital(con) : ''}
83
+ ${con ? renderCashPositionCard(con) : ''}
81
84
  ${result.comparison ? renderComparisonCard(result.comparison, result) : ''}
82
85
  <div class="section-label">Detalle por tienda</div>
83
86
  ${blocks}
@@ -268,6 +271,56 @@ function renderCapexCard(s) {
268
271
  </div>`;
269
272
  }
270
273
 
274
+ // Posición de caja · conciliación · efecto cambiario (base saldo REAL del banco). NO afecta el FCF.
275
+ function renderCashPositionCard(s) {
276
+ const cp = s.cash_position;
277
+ if (!cp || !cp.bank_accounts) return '';
278
+ const t = cp.totals;
279
+ const dev = cp.month_devaluation_pct != null ? ` · devaluación mes ${cp.month_devaluation_pct}%` : '';
280
+ const st = (store) => (store ? ` <span class="pnl-acct">${esc(store)}</span>` : '');
281
+
282
+ // Bucket 1 — bancos con statement: GL vs real, brecha, tasa implícita, fx_real
283
+ const bankRows = cp.bank_accounts.map((b) => {
284
+ const flag = b.flags && b.flags.length ? ` <span class="pnl-acct">⚠ ${esc(b.flags.join(','))}</span>` : '';
285
+ return `<div class="pnl-row sub">
286
+ <span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)}${flag}<br>
287
+ <span class="kpi-cell-sub">GL ${fmt(b.gl_ves)} → real ${fmt(b.stmt_ves)} VES · impl ${b.implied_rate || '—'} vs ${cp.rate_end}</span></span>
288
+ <span class="pnl-amt ${b.fx_real < 0 ? 'val-red' : 'val-green'}">${signed(b.fx_real)}</span>
289
+ </div>`;
290
+ }).join('');
291
+
292
+ // Bucket 3 — ajuste/tránsito (informativo)
293
+ const infoRows = (cp.informational || []).map((b) =>
294
+ `<div class="pnl-row sub"><span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)} <span class="pnl-acct">informativo</span></span><span class="pnl-amt">${signed(b.fx_theoretical)}</span></div>`
295
+ ).join('');
296
+ // Bucket 2 — sin statement (provisional)
297
+ const pendRows = (cp.pending_no_statement || []).map((b) =>
298
+ `<div class="pnl-row sub"><span class="pnl-label">${esc(b.account)} ${esc(b.name || '')}${st(b.store)} <span class="pnl-acct">sin statement</span></span><span class="pnl-amt">${signed(b.fx_provisional)}</span></div>`
299
+ ).join('');
300
+
301
+ // Asiento sugerido (solo bucket 1)
302
+ const j = cp.fx_journal;
303
+ const jRows = (j && j.rows || []).map((r) =>
304
+ `<div class="pnl-row sub"><span class="pnl-label">${esc(String(r.account))} <span class="pnl-acct">${esc(r.account_type)}</span></span><span class="pnl-amt">${r.debit_usd ? 'DR ' + signed(r.debit_usd) : 'CR ' + signed(r.credit_usd)}</span></div>`
305
+ ).join('');
306
+
307
+ const reconUsd = cp.rate_end ? t.recon_gap_ves / cp.rate_end : 0;
308
+ return `<div class="card">
309
+ <div class="card-hd"><span class="card-icon">&#x1F4B1;</span><span class="card-title">Posición de Caja · Conciliación · Efecto Cambiario (VES)</span></div>
310
+ <div class="card-bd"><div class="pnl">
311
+ <div class="pnl-row"><span class="pnl-label">Caja VES real (statement, a ${cp.rate_end} VES/USD)</span><span class="pnl-amt">${signed(t.ves_real_usd)}</span></div>
312
+ <div class="pnl-row"><span class="pnl-label">Brecha de conciliación (backlog a postear, NO es FX)</span><span class="pnl-amt">${fmt(t.recon_gap_ves)} VES (~${signed(reconUsd)})</span></div>
313
+ <div class="pnl-row gross"><span class="pnl-label">= Efecto cambiario sobre caja real${dev}</span><span class="pnl-amt ${t.fx_real < 0 ? 'val-red' : 'val-green'}">${signed(t.fx_real)}</span></div>
314
+ <div class="pnl-row sub"><span class="pnl-label">(ref. libro GL: ${signed(cp.gl_basis.ves_book_usd)} · FX s/GL ${signed(cp.gl_basis.fx_on_gl)})</span><span class="pnl-amt"></span></div>
315
+ <div class="pnl-section-hd">Bancos VES (saldo real vs libro)</div>
316
+ ${bankRows}
317
+ ${pendRows ? `<div class="pnl-section-hd">Sin statement (provisional)</div>${pendRows}` : ''}
318
+ ${infoRows ? `<div class="pnl-section-hd">Ajustes / Tránsito (informativo, fuera del total)</div>${infoRows}` : ''}
319
+ ${jRows ? `<div class="pnl-section-hd">Asiento sugerido — Diferencial Cambiario ${j.balanced ? '✓ cuadra' : '✗'}</div>${jRows}` : ''}
320
+ </div></div>
321
+ </div>`;
322
+ }
323
+
271
324
  // ─────────────────────────────────────────────────────────────────────────────
272
325
  // Comparativo de tiendas (multi)
273
326
  // ─────────────────────────────────────────────────────────────────────────────
@@ -39,6 +39,7 @@ import { round2 } from '../../utils/currency-converter.js';
39
39
  import { logger } from '../../utils/logger.js';
40
40
  import { fetchStoreFinancials, periodLabel, previousMonthRange } from './statements.js';
41
41
  import { buildCashFlowHTML } from './cash-flow-html.js';
42
+ import { computeCashPosition, consolidateCashPositions } from './fx-cash.js';
42
43
 
43
44
  const TAX_RATE = 0.34; // ISLR Venezuela estándar (estimado)
44
45
 
@@ -53,7 +54,9 @@ export const cashFlowTool = {
53
54
  'Parte del EBIT (igual que get_financial_statements), resta impuesto estimado (ISLR 34%), ajusta por capital de trabajo ' +
54
55
  '(7 líneas desde net-change G/L: Δ cuentas por cobrar, Δ impuestos corrientes, Δ prepagados, Δ inventario, Δ cuentas por pagar, ' +
55
56
  'Δ impuestos por pagar, Δ pasivos laborales) para obtener el flujo de caja operativo (CFO), y resta CapEx (PP&E 11110-12240) ' +
56
- 'para el FCF. Reporta también D&A y EBITDA (EBIT + D&A). Incluye NOPAT, margen FCF, status ' +
57
+ 'para el FCF. Reporta también D&A y EBITDA (EBIT + D&A). Incluye además la posición de caja con efecto cambiario: revalúa ' +
58
+ 'el saldo de las cuentas de banco/caja en bolívares (VES) a la tasa de fin de período y muestra cuánto sobrestiman los libros ' +
59
+ 'la caja real por la devaluación no contabilizada (no afecta el FCF). Incluye NOPAT, margen FCF, status ' +
57
60
  '(healthy/positive_low/negative_watch/' +
58
61
  'negative_critical), bridge tipo waterfall e insights automáticos. Multi-tienda calcula consolidado. Con render_html=true ' +
59
62
  'genera un dashboard HTML (lo guarda en ~/Downloads y retorna la ruta). Úsalo cuando el usuario pida flujo de caja, ' +
@@ -81,6 +84,11 @@ export const cashFlowTool = {
81
84
  description: 'Si true, incluye comparación con el mes anterior (MoM). Default: false.',
82
85
  default: false,
83
86
  },
87
+ include_cash_position: {
88
+ type: 'boolean',
89
+ description: 'Si true (default), calcula la posición de caja y el efecto cambiario sobre la caja VES (revaluación del saldo en bolívares a la tasa de fin de período). No afecta el FCF.',
90
+ default: true,
91
+ },
84
92
  render_html: {
85
93
  type: 'boolean',
86
94
  description: 'Si true, genera el dashboard HTML, lo guarda en ~/Downloads y retorna la ruta. Default: false.',
@@ -112,12 +120,22 @@ export async function getCashFlow(bcClient, params = {}) {
112
120
 
113
121
  const compare = !!params.compare_previous;
114
122
  const prevRange = compare ? previousMonthRange(start, end) : null;
115
-
116
- // ── Fetch en paralelo: cash flow actual + previo por tienda + exchange rate ──
123
+ const includeCashPosition = params.include_cash_position !== false; // default true
124
+
125
+ // Tipo de cambio VES: serie completa (compartida); se obtiene una vez y se pasa a las
126
+ // tiendas (la posición de caja la usa para revaluar el saldo VES a la tasa de fin de mes).
127
+ const rates = await bcClient.getExchangeRates(stores[0].code).catch((err) => {
128
+ logger.warn(`[cash-flow] exchange rate fetch failed: ${err.message}`);
129
+ return null;
130
+ });
131
+ const fx = rates ? bcClient.getExchangeRateForDate(rates, end) : null;
132
+
133
+ // ── Fetch en paralelo: cash flow actual + previo por tienda ──
134
+ // La posición de caja (FX) solo se calcula para el período actual (es saldo a fin de período).
117
135
  const tasks = [];
118
136
  for (const s of stores) {
119
137
  tasks.push(
120
- buildStoreCashFlow(bcClient, s, start, end)
138
+ buildStoreCashFlow(bcClient, s, start, end, { rates, includeCashPosition })
121
139
  .then((cf) => ({ kind: 'current', code: s.code, cf }))
122
140
  .catch((err) => ({ kind: 'current', code: s.code, error: err.message, store: s }))
123
141
  );
@@ -125,24 +143,14 @@ export async function getCashFlow(bcClient, params = {}) {
125
143
  if (compare) {
126
144
  for (const s of stores) {
127
145
  tasks.push(
128
- buildStoreCashFlow(bcClient, s, prevRange.start, prevRange.end)
146
+ buildStoreCashFlow(bcClient, s, prevRange.start, prevRange.end, { rates, includeCashPosition: false })
129
147
  .then((cf) => ({ kind: 'previous', code: s.code, cf }))
130
148
  .catch((err) => ({ kind: 'previous', code: s.code, error: err.message }))
131
149
  );
132
150
  }
133
151
  }
134
152
 
135
- const fxTask = bcClient
136
- .getExchangeRates(stores[0].code)
137
- .then((rates) => bcClient.getExchangeRateForDate(rates, end))
138
- .catch((err) => {
139
- logger.warn(`[cash-flow] exchange rate fetch failed: ${err.message}`);
140
- return null;
141
- });
142
-
143
- const settled = await Promise.all([fxTask, ...tasks]);
144
- const fx = settled[0];
145
- const fetched = settled.slice(1);
153
+ const fetched = await Promise.all(tasks);
146
154
 
147
155
  // ── Partición current / previous ──
148
156
  const storesOut = {};
@@ -201,13 +209,16 @@ export async function getCashFlow(bcClient, params = {}) {
201
209
  // Cash flow de una tienda
202
210
  // ─────────────────────────────────────────────────────────────────────────────
203
211
 
204
- async function buildStoreCashFlow(bcClient, store, start, end) {
212
+ async function buildStoreCashFlow(bcClient, store, start, end, opts = {}) {
205
213
  logger.info(`[cash-flow] ${store.code}: ${start} → ${end}`);
206
214
 
207
- // P&L base (income/cogs/EBIT) en paralelo con working capital + capex
208
- const [{ financials }, wc] = await Promise.all([
215
+ // P&L base (income/cogs/EBIT) en paralelo con working capital + capex + posición de caja (FX)
216
+ const [{ financials }, wc, cashPosition] = await Promise.all([
209
217
  fetchStoreFinancials(bcClient, store, start, end),
210
218
  fetchWorkingCapitalAndCapex(bcClient, store.companyId, start, end),
219
+ opts.includeCashPosition && opts.rates
220
+ ? computeCashPosition(bcClient, store, start, end, opts.rates).catch((e) => { logger.warn(`[cash-flow] cash position failed: ${e.message}`); return null; })
221
+ : Promise.resolve(null),
211
222
  ]);
212
223
 
213
224
  return assembleCashFlow(
@@ -228,11 +239,15 @@ async function buildStoreCashFlow(bcClient, store, start, end) {
228
239
  delta_labor: wc.delta_labor,
229
240
  capex: wc.capex,
230
241
  capex_accounts: wc.capex_accounts,
242
+ cash_position: cashPosition,
231
243
  warnings: wc.warnings,
232
244
  }
233
245
  );
234
246
  }
235
247
 
248
+ // La posición de caja + efecto cambiario (revaluación del saldo VES contra el saldo REAL del
249
+ // banco, aislando el backlog de conciliación) vive en ./fx-cash.js (módulo compartido).
250
+
236
251
  // Buckets del balance general (Chart of Accounts real de BC), por rango de cuenta.
237
252
  // REGLA UNIVERSAL de impacto en caja: delta = −Σ(debit − credit) sobre el rango.
238
253
  // - Activo que sube (debit) → −Σ < 0 = uso de caja.
@@ -359,7 +374,8 @@ function assembleCashFlow(meta, c) {
359
374
  const status = fcfStatus(fcf, fcfPct, ebit);
360
375
  const wc = { delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor };
361
376
  const bridge = buildBridge({ ebit, tax, nopat, ...wc, cfo, capex, fcf });
362
- const insights = buildInsights({ ebit, ...wc, da, capex, fcf, fcfPct, status, warnings: c.warnings || [] });
377
+ const cash_position = c.cash_position || null;
378
+ const insights = buildInsights({ ebit, ...wc, da, capex, fcf, fcfPct, status, cash_position, warnings: c.warnings || [] });
363
379
 
364
380
  return {
365
381
  store_code: meta.store_code,
@@ -376,6 +392,7 @@ function assembleCashFlow(meta, c) {
376
392
  cfo: { amount: cfo, pct: cfoPct },
377
393
  capex: { amount: capex, accounts: c.capex_accounts || {} },
378
394
  fcf: { amount: fcf, pct: fcfPct, status },
395
+ cash_position,
379
396
  fcf_bridge: bridge,
380
397
  insights,
381
398
  };
@@ -406,9 +423,33 @@ function buildBridge({ ebit, tax, nopat, delta_ar, delta_taxes, delta_prepaid, d
406
423
  ];
407
424
  }
408
425
 
409
- function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor, da, capex, fcf, fcfPct, status, warnings }) {
426
+ function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inventory, delta_ap, delta_taxes_payable, delta_labor, da, capex, fcf, fcfPct, status, cash_position, warnings }) {
410
427
  const insights = [];
411
428
 
429
+ // Posición de caja: separar la BRECHA DE CONCILIACIÓN (backlog en VES, a postear) del
430
+ // EFECTO CAMBIARIO sobre la caja real (asiento de diferencial cambiario). El FX se mide
431
+ // sobre el saldo real del banco (statement), no sobre el GL inflado por el backlog.
432
+ if (cash_position && cash_position.totals) {
433
+ const t = cash_position.totals;
434
+ if (Math.abs(t.recon_gap_ves) >= 1000) {
435
+ const gapUsd = cash_position.rate_end ? t.recon_gap_ves / cash_position.rate_end : 0;
436
+ insights.push(
437
+ `Brecha de conciliación: el GL excede al saldo real del banco en ${fmtAbs(t.recon_gap_ves)} VES ` +
438
+ `(~$${fmtAbs(gapUsd)}) — transacciones por postear; no es efecto cambiario.`
439
+ );
440
+ }
441
+ if (t.fx_real < 0 && Math.abs(t.fx_real) >= 1) {
442
+ const dev = cash_position.month_devaluation_pct != null ? ` (devaluación del mes ${cash_position.month_devaluation_pct}%)` : '';
443
+ insights.push(
444
+ `Efecto cambiario sobre caja VES real: −$${fmtAbs(t.fx_real)}${dev} — postear asiento de ` +
445
+ `diferencial cambiario. Caja VES real $${fmtAbs(t.ves_real_usd)} (vs $${fmtAbs(cash_position.gl_basis.ves_book_usd)} en libros GL).`
446
+ );
447
+ }
448
+ if (cash_position.pending_no_statement && cash_position.pending_no_statement.length) {
449
+ insights.push(`${cash_position.pending_no_statement.length} cuenta(s) VES sin statement — revaluación provisional, conciliar antes de postear.`);
450
+ }
451
+ }
452
+
412
453
  // Mayor driver negativo entre los usos de capital de trabajo (7 líneas)
413
454
  const uses = [
414
455
  { key: 'delta_inventory', label: 'Inventario', val: delta_inventory },
@@ -453,6 +494,7 @@ function buildInsights({ ebit, delta_ar, delta_taxes, delta_prepaid, delta_inven
453
494
  }
454
495
 
455
496
  insights.push(...warnings);
497
+ if (cash_position && cash_position.warnings) insights.push(...cash_position.warnings);
456
498
 
457
499
  if (insights.length === 0) {
458
500
  insights.push('Flujo de caja dentro de rangos normales.');
@@ -494,6 +536,7 @@ function consolidateCashFlow(storeObjs) {
494
536
  delta_labor: sum((o) => o.working_capital.delta_labor || 0),
495
537
  capex: sum((o) => o.capex.amount),
496
538
  capex_accounts,
539
+ cash_position: consolidateCashPositions(storeObjs.map((o) => (o.cash_position ? { ...o.cash_position, _store: o.store_code } : null))),
497
540
  warnings: allWarnings,
498
541
  }
499
542
  );
@@ -0,0 +1,227 @@
1
+ // tools/financials/fx-cash.js
2
+ //
3
+ // Módulo compartido: posición de caja + efecto cambiario sobre la caja VES, revaluando
4
+ // contra el SALDO REAL del banco (StatementEndingBalance), no el saldo GL (que puede estar
5
+ // inflado por backlog de conciliación). Usado por get_cash_flow (vista) y, a futuro, por el
6
+ // paso de cierre-mensual (asiento). READ-ONLY. NO afecta el FCF.
7
+ //
8
+ // MATEMÁTICA (por cuenta VES, robusta al backlog):
9
+ // gl_usd = Σ(debit−credit) USD libro
10
+ // gl_ves = Σ(additionalCurrency d−c) VES libro (puede estar inflado)
11
+ // implied_rate = gl_ves / gl_usd tasa promedio histórica de posteo
12
+ // stmt_ves = StatementEndingBalance VES real (del banco)
13
+ // recon_gap = gl_ves − stmt_ves backlog pendiente de postear (NO es FX)
14
+ // real_usd = stmt_ves / rate_end valor USD real de la caja
15
+ // fx_real = stmt_ves × (1/rate_end − 1/implied_rate) ← pérdida cambiaria sobre lo REAL
16
+ //
17
+ // 3 buckets de cuentas VES:
18
+ // 1) banco real con statement → fx_real, ENTRA al total y al asiento.
19
+ // 2) banco real sin statement → provisional sobre GL, flag, NO entra al total.
20
+ // 3) ajuste/tránsito (lista en config) → solo informativo, NO entra al total.
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { round2 } from '../../utils/currency-converter.js';
25
+ import { logger } from '../../utils/logger.js';
26
+
27
+ // ── Config bank-gl-map (cargado una vez) ───────────────────────────────────────
28
+ let _bankMap = null;
29
+ function bankMap() {
30
+ if (_bankMap) return _bankMap;
31
+ const path = fileURLToPath(new URL('../../config/bank-gl-map.json', import.meta.url));
32
+ _bankMap = JSON.parse(readFileSync(path, 'utf-8'));
33
+ return _bankMap;
34
+ }
35
+
36
+ // Cuentas VES de ajuste/tránsito (bucket 3) — informativas, fuera del total/asiento.
37
+ function adjustmentAccounts() {
38
+ return new Set(bankMap().adjustment_accounts_ves || []);
39
+ }
40
+
41
+ // Mapa GL→banco para una tienda: { '18250': { bankCode: 'FQ88-MN0005', name: 'Bancrecer 2558 Bs.' } }
42
+ // Solo cuentas VES (el statement está en VES).
43
+ function glToBankForStore(storeCode) {
44
+ const store = (bankMap().stores || {})[storeCode];
45
+ const out = {};
46
+ if (!store) return out;
47
+ for (const [suffix, info] of Object.entries(store.accounts || {})) {
48
+ if (!info.gl || info.currency !== 'VES') continue;
49
+ out[String(info.gl)] = { bankCode: `${storeCode}-${suffix}`, name: info.name || '' };
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // Regla VES (confirmada por FP): bancos VES = 18200–18299 ; caja VEB = 18110/18130/18160.
55
+ const VES_CASH_ACCOUNTS = new Set(['18110', '18130', '18160']);
56
+ export function isVesCashAccount(accountNumber) {
57
+ const n = parseInt(accountNumber, 10);
58
+ return (n >= 18200 && n <= 18299) || VES_CASH_ACCOUNTS.has(String(accountNumber));
59
+ }
60
+
61
+ const RATE_MIN = 350, RATE_MAX = 750; // banda sana para la tasa implícita (flag si está fuera)
62
+
63
+ // ── Cálculo principal ──────────────────────────────────────────────────────────
64
+ // store = { code, name, companyId, companyName }
65
+ export async function computeCashPosition(bcClient, store, start, end, rates) {
66
+ const [entries, statements] = await Promise.all([
67
+ bcClient.getCashGLBalances(store.companyId, end).catch((e) => { logger.warn(`[fx-cash] GL balances failed: ${e.message}`); return null; }),
68
+ bcClient.getBankStatementBalances(store.code, end).catch((e) => { logger.warn(`[fx-cash] statement balances failed: ${e.message}`); return null; }),
69
+ ]);
70
+ if (entries == null) return null;
71
+
72
+ const rateEnd = (bcClient.getExchangeRateForDate(rates, end) || {}).rate || null;
73
+ const rateStart = (bcClient.getExchangeRateForDate(rates, start) || {}).rate || null;
74
+ if (!rateEnd) return null;
75
+
76
+ const warnings = [];
77
+ if (entries._truncated) warnings.push('G/L de caja/bancos truncado — saldos pueden estar incompletos.');
78
+ if (statements == null) warnings.push('Reconciliaciones bancarias no disponibles — sin saldo real; revaluación provisional sobre GL.');
79
+
80
+ const glToBank = glToBankForStore(store.code);
81
+ const adjustment = adjustmentAccounts();
82
+ const stmts = statements || {};
83
+
84
+ // Acumular saldos GL por cuenta (USD libro + VES additionalCurrency)
85
+ const acc = {};
86
+ for (const e of entries) {
87
+ const a = e.accountNumber;
88
+ if (!acc[a]) acc[a] = { usd: 0, ves: 0 };
89
+ acc[a].usd += (e.debitAmount || 0) - (e.creditAmount || 0);
90
+ acc[a].ves += (e.additionalCurrencyDebitAmount || 0) - (e.additionalCurrencyCreditAmount || 0);
91
+ }
92
+
93
+ const bank_accounts = []; // bucket 1
94
+ const pending_no_statement = []; // bucket 2
95
+ const informational = []; // bucket 3
96
+ let usdTotal = 0;
97
+ let glVesBookSum = 0, glVesAtRateSum = 0; // referencia estilo v1.26.0 (todas las VES sobre GL)
98
+
99
+ for (const [a, v] of Object.entries(acc)) {
100
+ const glUsd = round2(v.usd);
101
+ const glVes = round2(v.ves);
102
+ if (Math.abs(glUsd) < 0.005 && Math.abs(glVes) < 0.5) continue; // saldo cero
103
+
104
+ if (!isVesCashAccount(a)) { usdTotal += glUsd; continue; } // cuentas USD: sin ajuste
105
+
106
+ // Referencia GL (v1.26.0): revaluar el saldo GL completo
107
+ glVesBookSum += glUsd;
108
+ glVesAtRateSum += glVes / rateEnd;
109
+
110
+ const impliedRate = glVes !== 0 && glUsd > 0 ? round2(glVes / glUsd) : null;
111
+ const flags = [];
112
+ if (impliedRate != null && (impliedRate < RATE_MIN || impliedRate > RATE_MAX)) flags.push('tasa_implicita_atipica');
113
+ const info = glToBank[a];
114
+ const stmt = info ? stmts[info.bankCode] : null;
115
+
116
+ // fx sobre un saldo VES dado, a la tasa implícita de la cuenta
117
+ const fxOn = (vesBalance) => (impliedRate ? round2(vesBalance * (1 / rateEnd - 1 / impliedRate)) : 0);
118
+
119
+ if (adjustment.has(String(a))) {
120
+ // Bucket 3 — ajuste/tránsito: informativo
121
+ informational.push({ account: a, name: info ? info.name : 'Ajuste/Tránsito VES', gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate, fx_theoretical: fxOn(glVes), flags: [...flags, 'ajuste_transito'] });
122
+ } else if (info && stmt) {
123
+ // Bucket 1 — banco real con statement: revaluar saldo REAL
124
+ const stmtVes = round2(stmt.ending || 0);
125
+ const realUsd = round2(stmtVes / rateEnd);
126
+ const fxReal = fxOn(stmtVes);
127
+ if (stmt.stale) flags.push('statement_desactualizado');
128
+ bank_accounts.push({
129
+ account: a, bank_code: info.bankCode, name: info.name,
130
+ gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate,
131
+ stmt_ves: stmtVes, statement_date: stmt.statementDate || null,
132
+ recon_gap_ves: round2(glVes - stmtVes), real_usd: realUsd, fx_real: fxReal, flags,
133
+ });
134
+ } else {
135
+ // Bucket 2 — banco/caja sin statement: provisional sobre GL
136
+ pending_no_statement.push({ account: a, name: info ? info.name : 'VES sin statement', gl_usd: glUsd, gl_ves: glVes, implied_rate: impliedRate, fx_provisional: fxOn(glVes), flags: [...flags, 'sin_statement'] });
137
+ }
138
+ }
139
+
140
+ bank_accounts.sort((x, y) => x.fx_real - y.fx_real);
141
+ pending_no_statement.sort((x, y) => x.fx_provisional - y.fx_provisional);
142
+ informational.sort((x, y) => x.fx_theoretical - y.fx_theoretical);
143
+
144
+ const sum = (arr, fn) => round2(arr.reduce((s, o) => s + fn(o), 0));
145
+ const totals = {
146
+ ves_real_usd: sum(bank_accounts, (o) => o.real_usd),
147
+ fx_real: sum(bank_accounts, (o) => o.fx_real),
148
+ recon_gap_ves: sum(bank_accounts, (o) => o.recon_gap_ves),
149
+ bank_book_usd: sum(bank_accounts, (o) => o.gl_usd),
150
+ pending_book_usd: sum(pending_no_statement, (o) => o.gl_usd),
151
+ informational_book_usd: sum(informational, (o) => o.gl_usd),
152
+ usd_accounts_usd: round2(usdTotal),
153
+ };
154
+ totals.total_cash_real_usd = round2(totals.ves_real_usd + usdTotal);
155
+
156
+ const fx_journal = buildFxJournal(bank_accounts);
157
+
158
+ return {
159
+ rate_end: round2(rateEnd),
160
+ rate_start: rateStart ? round2(rateStart) : null,
161
+ month_devaluation_pct: rateStart && rateStart > 0 ? round2((rateEnd / rateStart - 1) * 100) : null,
162
+ bank_accounts,
163
+ pending_no_statement,
164
+ informational,
165
+ usd: { total_usd: round2(usdTotal) },
166
+ totals,
167
+ gl_basis: { ves_book_usd: round2(glVesBookSum), ves_at_rate_usd: round2(glVesAtRateSum), fx_on_gl: round2(glVesAtRateSum - glVesBookSum) },
168
+ fx_journal,
169
+ warnings,
170
+ };
171
+ }
172
+
173
+ // ── Asiento sugerido de diferencial cambiario (READ-ONLY texto) — solo bucket 1 ──
174
+ // DR Pérdida Diferencial Cambiario / CR cada banco VES (en USD) por su fx_real.
175
+ export function buildFxJournal(bankAccounts) {
176
+ const PNL_ACCOUNT = { account: 'Diferencial Cambiario', note: 'Pérdida/Ganancia por diferencial cambiario (P&L)' };
177
+ const rows = [];
178
+ let bankDebit = 0, bankCredit = 0;
179
+ for (const b of bankAccounts) {
180
+ if (Math.abs(b.fx_real) < 0.005) continue;
181
+ // pérdida (fx_real<0) → CR banco ; ganancia (fx_real>0) → DR banco
182
+ const debit = b.fx_real > 0 ? round2(b.fx_real) : 0;
183
+ const credit = b.fx_real < 0 ? round2(-b.fx_real) : 0;
184
+ bankDebit += debit; bankCredit += credit;
185
+ rows.push({ account: b.account, name: b.name, account_type: 'Banco (G/L)', debit_usd: debit, credit_usd: credit });
186
+ }
187
+ const net = round2(bankCredit - bankDebit); // >0 = pérdida neta
188
+ // Contrapartida P&L
189
+ rows.push({
190
+ account: PNL_ACCOUNT.account, name: PNL_ACCOUNT.note, account_type: 'G/L (P&L)',
191
+ debit_usd: net > 0 ? net : 0, credit_usd: net < 0 ? round2(-net) : 0,
192
+ });
193
+ const debitTotal = round2(rows.reduce((s, r) => s + r.debit_usd, 0));
194
+ const creditTotal = round2(rows.reduce((s, r) => s + r.credit_usd, 0));
195
+ return { rows, debit_total: debitTotal, credit_total: creditTotal, balanced: Math.abs(debitTotal - creditTotal) < 0.01, net_fx: net };
196
+ }
197
+
198
+ // Consolida posiciones de varias tiendas (suma bucket 1/2/3 y rehace totales/asiento).
199
+ export function consolidateCashPositions(positions) {
200
+ const ps = positions.filter(Boolean);
201
+ if (!ps.length) return null;
202
+ const ref = ps[0];
203
+ const tag = (arr, code) => arr.map((o) => ({ ...o, store: code }));
204
+ const bank_accounts = ps.flatMap((p) => tag(p.bank_accounts, p._store)).sort((a, b) => a.fx_real - b.fx_real);
205
+ const pending_no_statement = ps.flatMap((p) => tag(p.pending_no_statement, p._store));
206
+ const informational = ps.flatMap((p) => tag(p.informational, p._store));
207
+ const sum = (fn) => round2(ps.reduce((s, p) => s + fn(p), 0));
208
+ const usdTotal = sum((p) => p.usd.total_usd);
209
+ const totals = {
210
+ ves_real_usd: sum((p) => p.totals.ves_real_usd),
211
+ fx_real: sum((p) => p.totals.fx_real),
212
+ recon_gap_ves: sum((p) => p.totals.recon_gap_ves),
213
+ bank_book_usd: sum((p) => p.totals.bank_book_usd),
214
+ pending_book_usd: sum((p) => p.totals.pending_book_usd),
215
+ informational_book_usd: sum((p) => p.totals.informational_book_usd),
216
+ usd_accounts_usd: round2(usdTotal),
217
+ };
218
+ totals.total_cash_real_usd = round2(totals.ves_real_usd + usdTotal);
219
+ return {
220
+ rate_end: ref.rate_end, rate_start: ref.rate_start, month_devaluation_pct: ref.month_devaluation_pct,
221
+ bank_accounts, pending_no_statement, informational,
222
+ usd: { total_usd: round2(usdTotal) }, totals,
223
+ gl_basis: { ves_book_usd: sum((p) => p.gl_basis.ves_book_usd), ves_at_rate_usd: sum((p) => p.gl_basis.ves_at_rate_usd), fx_on_gl: sum((p) => p.gl_basis.fx_on_gl) },
224
+ fx_journal: buildFxJournal(bank_accounts),
225
+ warnings: [...new Set(ps.flatMap((p) => p.warnings))],
226
+ };
227
+ }