@fullqueso/mcp-bc-gastos 1.17.0 → 1.23.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/config/bank-gl-map.json +93 -0
  3. package/config/bank-keywords.js +35 -0
  4. package/lib/bc-client.js +73 -13
  5. package/package.json +3 -2
  6. package/scripts/diagnose-closing.js +144 -0
  7. package/scripts/generate-month-closing-xlsx.py +325 -0
  8. package/scripts/test-closing-flow.js +71 -0
  9. package/scripts/test-closing-fq01-dec25.js +78 -0
  10. package/server.js +77 -0
  11. package/tools/auditoria/bank-ledger-entries.js +3 -5
  12. package/tools/auditoria/find-potential-matches.js +1 -1
  13. package/tools/auditoria/gl-account-entries.js +1 -1
  14. package/tools/auditoria/list-bank-accounts.js +23 -12
  15. package/tools/auditoria/pm-receipts.js +4 -4
  16. package/tools/auditoria/reconcile-pos-sales.js +46 -21
  17. package/tools/auditoria/unmatched-ledger-entries.js +3 -3
  18. package/tools/auditoria/unmatched-statement-lines.js +2 -2
  19. package/tools/cierre-mensual/fetch-ledger.js +69 -0
  20. package/tools/cierre-mensual/generate-closing-journal.js +111 -0
  21. package/tools/cierre-mensual/get-match-results.js +151 -0
  22. package/tools/cierre-mensual/get-questionnaire.js +283 -0
  23. package/tools/cierre-mensual/index.js +6 -0
  24. package/tools/cierre-mensual/journal-builder.js +219 -0
  25. package/tools/cierre-mensual/matchers/index.js +161 -0
  26. package/tools/cierre-mensual/matchers/match-cross-bank.js +168 -0
  27. package/tools/cierre-mensual/matchers/match-draft-payments.js +178 -0
  28. package/tools/cierre-mensual/matchers/match-feedback-loop.js +24 -0
  29. package/tools/cierre-mensual/matchers/match-keywords.js +65 -0
  30. package/tools/cierre-mensual/matchers/match-open-arap.js +66 -0
  31. package/tools/cierre-mensual/matchers/match-pos-terminal.js +53 -0
  32. package/tools/cierre-mensual/reconcile-with-bc.js +116 -0
  33. package/tools/cierre-mensual/start-month-closing.js +234 -0
  34. package/tools/cierre-mensual/state-store.js +211 -0
  35. package/tools/cierre-mensual/submit-answers.js +106 -0
  36. package/tools/cobranzas/customer-ledger.js +4 -4
  37. package/tools/cobranzas/customer-list.js +63 -0
  38. package/tools/cobranzas/vendor-ledger.js +4 -4
  39. package/tools/financials/aggregator.js +360 -0
  40. package/tools/financials/cash-flow-html.js +459 -0
  41. package/tools/financials/cash-flow.js +471 -0
  42. package/tools/financials/html-template.js +674 -0
  43. package/tools/financials/index.js +79 -0
  44. package/tools/financials/statements.js +296 -0
  45. package/tools/inventario/index.js +3 -0
  46. package/tools/inventario/inventory-by-location.js +212 -0
  47. package/tools/inventario/inventory-change.js +386 -0
  48. package/tools/inventario/inventory-levels.js +214 -0
  49. package/tools/inventario/item-card.js +1 -50
  50. package/tools/inventario/item-ledger-entries.js +4 -4
  51. package/tools/inventario/item-value-entries.js +2 -2
  52. package/tools/inventario/shared/cost-calculator.js +64 -0
  53. package/tools/ventas/index.js +1 -0
  54. package/tools/ventas/item-sales-detail.js +366 -0
  55. package/utils/rate-limiter.js +84 -0
package/CHANGELOG.md CHANGED
@@ -1,10 +1,105 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [1.23.0] — 2026-05-31
4
+
5
+ ### Added
6
+ - **Dominio `financials/`: tool `get_cash_flow`** — Free Cash Flow (FCF) por método indirecto para una o más tiendas FQ.
7
+ - Parte del EBIT (reutiliza `fetchStoreFinancials` de `get_financial_statements`), resta impuesto estimado (ISLR 34%, solo si EBIT>0) → NOPAT, ajusta por capital de trabajo (ΔAR, ΔAP, Δinventario) → CFO, resta CapEx (activos fijos 15000-17999) → FCF.
8
+ - Fuentes BC: AR/AP vía Finance Beta (`customerLedgerEntries`/`vendorLedgerEntries`, USD = `amountLocalCurrency`), inventario vía `itemLedgerEntries` (`costAmountActual`), CapEx vía `getGLEntries`. Todas en paralelo (`Promise.all`) junto al P&L.
9
+ - Convención de signos: cada delta = impacto en caja (+ = fuente, − = uso); `net_wc_change = ΔAR+ΔAP+Δinv`; `cfo = nopat + net_wc_change`; `fcf = cfo − capex`. (Resuelve la inconsistencia del spec entre comentarios y fórmula a favor de la convención de impacto-en-caja, autoconsistente.)
10
+ - `fcf_status` (healthy/positive_low/negative_watch/negative_critical), `fcf_bridge` (waterfall) e `insights` automáticos. Consolidado multi-tienda + comparación MoM (con `no_previous_data` para tiendas sin mes anterior).
11
+ - `render_html=true`: dashboard HTML reutilizando el design system de financials + **waterfall CSS** del FCF bridge (sin librerías externas). Guarda en `~/Downloads/FQ_CashFlow_[STORE|Consolidado]_YYYYMM.html`.
12
+ - **`tools/financials/cash-flow.js`** y **`tools/financials/cash-flow-html.js`**. Exportados helpers reutilizables (`CSS`, `fmt`, `esc`, `clamp`, `scoreColorName`, `periodLabel`, `previousMonthRange`) desde `html-template.js`/`statements.js`.
13
+
14
+ ### Notas
15
+ - Si un endpoint BC (AR/AP/inventario/activos fijos) devuelve vacío o falla, ese delta se asume 0 y se agrega un warning a `insights` (no rompe el cálculo).
16
+ - Si una tienda falla, las demás continúan; el error queda en el campo de esa tienda.
17
+ - Tool count: 53 → 54.
18
+
19
+ ---
20
+
21
+ ## [1.22.0] — 2026-05-31
22
+
23
+ ### Added
24
+ - **Dominio `financials/`: tool `get_financial_statements`** — Estado financiero completo (P&L) para una o más tiendas FQ en un período.
25
+ - P&L completo: ingresos por cuenta, COGS, margen bruto, gastos por categoría (con benchmarks), margen operativo.
26
+ - Queries BC en paralelo `Promise.all` (3 tiendas × 3 queries = 9 simultáneas) + exchange rate compartido.
27
+ - Consolidado multi-tienda re-agregando todas las GL entries crudas (consistencia exacta con la suma de tiendas).
28
+ - Template HTML estático (design system FQ: tokens Dark/Light, header rojo, ring de score, toggle, TL;DR, grid KPIs, tabla P&L, barras de gastos, comparativa multi-tienda, `@media print`). `render_html=true` guarda en `~/Downloads` y retorna la ruta.
29
+ - Comparación mes-a-mes con `compare_previous=true`. Si el período anterior no tiene datos (la tienda aún no operaba en BC, p.ej. FQ01 antes de Dic 2025), se marca `no_previous_data: true` en vez de comparar contra cero (evita score/varianzas fabricados).
30
+ - Score 0-100 ponderado por status de 7 ratios vs benchmarks del sector.
31
+ - **`tools/financials/aggregator.js`** — `EXPENSE_CATEGORIES`, `getStatus`, `aggregateIncome`, `aggregateCOGS`, `aggregateExpenses`, `calcRatios`, `calcScore`, `generateInsights`. Reutiliza `config/expense-accounts.js` y `config/benchmarks.js`.
32
+
33
+ ### Notas
34
+ - Reutiliza el `bcClient.getGLEntries()` existente (convención `debitAmount`/`creditAmount`, no el `amount` de Finance Beta). Paginación automática vía `apiCallAllPages`.
35
+ - Bump desde 1.21.0 → 1.22.0 (el spec mencionaba "1.2.0" cuando la versión base era anterior; 1.2.0 sería un downgrade, por eso MINOR sobre la versión real publicada).
36
+ - Tool count: 52 → 53.
37
+
38
+ ### Validado (FQ01 Diciembre 2025)
39
+ - `income.total` = $157,693 · `cogs.total` = $100,158 · `gross_margin.pct` = 36.49% · `expenses.otros.amount` = $19,233 (mermas $16,933) · `operating_income.pct` = 10.6% · `score` ≈ 42.
40
+
41
+ ---
42
+
43
+ ## [1.20.0] - 2026-05-04
44
+
45
+ ### Added
46
+ - **`get_item_sales_detail` tool (ventas)** — Detalle de ventas por SKU (1–50 ítems) con granularidad day/week/month/total y desglose por tienda. Pensado para análisis de promos, canibalización, lanzamientos y SKUs estacionales que caen fuera del top-20 de `get_sales_analysis`. Fuente: `itemLedgerEntries` con `entryType eq 'Sale'` (mismos signos que el screenshot del Item Application Worksheet — quantity y costAmountActual negativos en BC, invertidos a positivos en la salida). Soporta `include_zero_days` para detección de stockouts y series temporales limpias. Multi-company en paralelo (FQ01/FQ28/FQ88).
47
+ - **`bcClient.getItemLedgerSaleEntries(companyId, { items, startDate, endDate })`** — Helper en `lib/bc-client.js` para fetch paginado de ILE Sale entries por lote de ítems.
48
+
49
+ ### Changed
50
+ - Tool count: 45 → 46
51
+
52
+ ---
53
+
54
+ ## [1.19.0] - 2026-04-13
55
+
56
+ ### Added
57
+ - **`get_customer_list` tool (cobranzas)** — Lists customers with number, name, and RIF (taxRegistrationNumber) per store. Supports optional filter by customer number.
58
+ - **`config/bank-gl-map.json`** — Reference config documenting the full bank account suffix → GL account mapping per store (FQ01, FQ28, FQ88), including GL structure for Caja y Bancos (181xx–189xx).
59
+
60
+ ### Changed
61
+ - **POS reconciliation: per-store bank GL mapping** — `BANK_GL_MAP` in `reconcile-pos-sales.js` now keyed by store (FQ01, FQ28) instead of flat suffix lookup. Adds cash register accounts (MN0030, ME0030) and full FQ28 bank set. `getBankGLAccount()` now accepts store parameter for correct GL resolution.
62
+ - Tool count: 44 → 45
63
+
64
+ ---
65
+
66
+ ## [1.18.2] - 2026-04-13
67
+
68
+ ### Fixed
69
+ - **Inventory tools: exclude FQ-\* sale/combo categories** — `get_inventory_levels`, `get_inventory_change`, and `get_inventory_by_location` now filter out items with `itemCategoryCode` starting with `FQ-`, `FQ_`, or equal to `FQVENTA`. These are sales/combo products, not real inventory, and their BC `unitCost` can be contaminated (zero-inventory edge cases), producing inflated inventory valuations. Real inventory items (TERMINADO, INSUMO, etc.) keep BC's FIFO-based `unitCost` which is accurate.
70
+
71
+ ### Changed
72
+ - **Extracted `getCalculatedCosts` to shared module** — Moved from `item-card.js` to `tools/inventario/shared/cost-calculator.js` along with `isInventoryCategory()`. Avoids duplication across inventory tools.
73
+ - **Updated `calculated_unit_cost_method.md`** — Documented category filtering logic and clarified that calculated cost is for FQ-* items only; real inventory uses BC FIFO cost.
74
+
75
+ ---
76
+
77
+ ## [1.18.1] - 2026-04-13
78
+
79
+ ### Changed
80
+ - `get_inventory_by_location` — Added `as_of_date` parameter for historical inventory reconstruction by location. Filters OData V4 item ledger entries with `Posting_Date <= as_of_date`. Also added `isInventoryCategory` filter to exclude FQ-* sale/combo items (aligns with `get_inventory_levels` behavior).
81
+
82
+ ---
83
+
84
+ ## [1.18.0] - 2026-03-19
85
+
86
+ ### Added
87
+
88
+ **Inventario domain — 2 new tools (inventory levels & change tracking):**
89
+ - `get_inventory_levels` — Inventory snapshot grouped by itemCategoryCode and inventoryPostingGroupCode (congelados/imported/local). Supports historical reconstruction via `as_of_date` (e.g., 1st of month post-EOM adjustment). Returns classification matrix with qty, cost value, and top items per group.
90
+ - `get_inventory_change` — WoW and MoM inventory change analysis. Reconstructs opening/closing inventory per period walking backwards from current balance. Movement breakdown by type (purchases, sales, adjustments, assembly). Trend summary per category and classification.
91
+
92
+ ### Changed
93
+ - Tool count: 42 → 44
94
+ - Inventario domain: 4 → 6 tools
95
+
96
+ ---
97
+
3
98
  ## [1.17.0] - 2026-03-18
4
99
 
5
100
  ### Highlights
6
101
  - **Ventas domain consolidated** from `mcp-bc-analisis-ventas` — 3 new sales analysis tools
7
- - Full Queso now has one unified BC MCP with 41 tools across 8 domains
102
+ - Full Queso now has one unified BC MCP with **42 tools** across 8 domains
8
103
 
9
104
  ### Added
10
105
 
@@ -0,0 +1,93 @@
1
+ {
2
+ "version": "1.0",
3
+ "last_updated": "2026-05-17",
4
+ "description": "Mapeo banco_code (BC Bank Account suffix) → cuenta GL (Chart of Accounts). Usado por POS reconciliation y journal entry suggestions. Cada tienda tiene su propio CoA con bancos distintos.",
5
+ "stores": {
6
+ "FQ01": {
7
+ "store_name": "FQ01 - Chacao Sambil",
8
+ "accounts": {
9
+ "MN0001": { "gl": "18210", "name": "Banesco 4421 Bs.", "currency": "VES", "category": "bancos_nacionales" },
10
+ "MN0002": { "gl": "18220", "name": "BDV 1925 Bs.", "currency": "VES", "category": "bancos_nacionales" },
11
+ "MN0003": { "gl": "18230", "name": "Bancamiga 7015 Bs.", "currency": "VES", "category": "bancos_nacionales" },
12
+ "MN0004": { "gl": "18240", "name": "BDV 5550 Bs.", "currency": "VES", "category": "bancos_nacionales" },
13
+ "MN0005": { "gl": "18250", "name": "Bancamiga 4523 Bs.", "currency": "VES", "category": "bancos_nacionales" },
14
+ "MN0006": { "gl": "18260", "name": "Bancrecer 2450 Bs.", "currency": "VES", "category": "bancos_nacionales" },
15
+ "MN0007": { "gl": "18270", "name": "Bancrecer 2467 Bs.", "currency": "VES", "category": "bancos_nacionales" },
16
+ "MN0008": { "gl": "18280", "name": "BDV 5187 Bs. (Pago Móvil)", "currency": "VES", "category": "bancos_nacionales" },
17
+ "MN0012": { "gl": "18290", "name": "UBII", "currency": "VES", "category": "bancos_nacionales" },
18
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
19
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
20
+ "ME0006": { "gl": null, "name": "Zelle USD (Wells Fargo)", "currency": "USD", "category": "bancos_extranjeros", "_nota": "GL pendiente de confirmar" }
21
+ }
22
+ },
23
+ "FQ28": {
24
+ "store_name": "FQ28 - Marqués El Unicentro",
25
+ "accounts": {
26
+ "MN0001": { "gl": "18210", "name": "BDV 8139 Bs.", "currency": "VES", "category": "bancos_nacionales" },
27
+ "MN0002": { "gl": "18220", "name": "Bancrecer 5474 Bs.", "currency": "VES", "category": "bancos_nacionales" },
28
+ "MN0028": { "gl": "18290", "name": "UBII 6249 Bs", "currency": "VES", "category": "bancos_nacionales", "_nota": "Punto 2 + Punto 3." },
29
+ "MN0029": { "gl": "18291", "name": "UBII 6442 Bs", "currency": "VES", "category": "bancos_nacionales", "_nota": "Punto 4 + Punto 7." },
30
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
31
+ "MN0031": { "gl": "18160", "name": "Caja transitoria tienda ventas Bs.", "currency": "VES", "category": "caja" },
32
+ "MN0032": { "gl": "18292", "name": "UBII 4 y 7 (duplicada)", "currency": "VES", "category": "bancos_nacionales", "_nota": "Cuenta duplicada creada por error — NO usar. Punto 4 y Punto 7 van a MN0029. Confirmado 2026-05-17." },
33
+ "MN0033": { "gl": "18295", "name": "UBII 7 bloqueado (duplicada)", "currency": "VES", "category": "bancos_nacionales", "_nota": "Cuenta duplicada creada por error — NO usar. Punto 7 va a MN0029. Confirmado 2026-05-17." },
34
+ "MN0098": { "gl": "18298", "name": "Operaciones en Tránsito", "currency": "VES", "category": "bancos_nacionales" },
35
+ "ME0002": { "gl": "18310", "name": "Bancrecer $", "currency": "USD", "category": "bancos_nacionales_divisas" },
36
+ "ME0005": { "gl": "18410", "name": "Zelle Bofa $ *", "currency": "USD", "category": "bancos_extranjeros" },
37
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
38
+ "ME0031": { "gl": "18170", "name": "Caja transitoria tienda ventas $", "currency": "USD", "category": "caja" },
39
+ "ME0035": { "gl": "18150", "name": "Banco Bóveda $", "currency": "USD", "category": "caja" }
40
+ }
41
+ },
42
+ "FQ88": {
43
+ "store_name": "FQ88 - La Candelaria",
44
+ "_nota": "GL accounts pendientes de confirmar con CoA de FQ88",
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" },
53
+ "MN0030": { "gl": "18130", "name": "Caja tienda ventas Bs.", "currency": "VES", "category": "caja" },
54
+ "ME0030": { "gl": "18140", "name": "Caja tienda ventas $", "currency": "USD", "category": "caja" },
55
+ "ME0005": { "gl": null, "name": "Zelle USD", "currency": "USD", "category": "bancos_extranjeros" }
56
+ }
57
+ }
58
+ },
59
+ "gl_structure": {
60
+ "18100": "Caja (header)",
61
+ "18110": "Caja chica VEB",
62
+ "18120": "Caja chica $",
63
+ "18130": "Caja tienda ventas VEB",
64
+ "18140": "Caja tienda ventas $",
65
+ "18150": "Bóveda $",
66
+ "18160": "Caja tránsito tienda ventas VEB",
67
+ "18170": "Caja tránsito tienda ventas $",
68
+ "18199": "Total Caja",
69
+ "18200": "Bancos Nacionales Bolívares (header)",
70
+ "18210": "Banco principal Bs.",
71
+ "18220": "Banco secundario Bs.",
72
+ "18230": "Banco terciario Bs.",
73
+ "18240": "Banco cuarto Bs.",
74
+ "18250": "Banco quinto Bs.",
75
+ "18260": "Banco sexto Bs.",
76
+ "18270": "Banco séptimo Bs.",
77
+ "18280": "Banco octavo Bs.",
78
+ "18290": "UBII principal",
79
+ "18291": "UBII secundario",
80
+ "18292": "UBII terciario",
81
+ "18295": "UBII cuarto",
82
+ "18298": "Operaciones en Tránsito",
83
+ "18299": "Total Bancos Nacionales",
84
+ "18300": "Bancos Nacionales Divisas (header)",
85
+ "18310": "Banco divisas principal",
86
+ "18399": "Total Bancos Nacionales Divisas",
87
+ "18400": "Bancos Extranjeros Divisas (header)",
88
+ "18410": "Banco extranjero principal",
89
+ "18499": "Total Bancos Extranjeros Divisas",
90
+ "18998": "Total Bancos",
91
+ "18999": "Total Caja y Bancos"
92
+ }
93
+ }
@@ -30,6 +30,41 @@ export const BANK_KEYWORDS = [
30
30
  // Transfers
31
31
  { keywords: ['TRANSF RECIBIDA', 'TRANSFERENCIA RECIBIDA'], account: '11000', name: 'Transferencia Recibida' },
32
32
  { keywords: ['TRANSF ENVIADA', 'TRANSFERENCIA ENVIADA'], account: '11000', name: 'Transferencia Enviada' },
33
+ { keywords: ['PAGO RECIBIDO OTROS BANCOS', 'ABONO RECIBIDO OTR BCOS'], account: '11000', name: 'Pago Recibido Otros Bancos' },
34
+ { keywords: ['PAGOMOVIL', 'PAGO MOVIL', 'PAGOMOVILBDV'], account: '11000', name: 'Pago Móvil' },
35
+ { keywords: ['TRANS.CTAS', 'TRASPASO OTRAS CTAS', 'TRF.MB', 'TRF CR INM', 'TRF DR INM'], account: '11000', name: 'Traspaso entre cuentas' },
36
+ { keywords: ['TRANSF EN LINEA OTR BCOS', 'TRANSF EN LINEA'], account: '11000', name: 'Transferencia en línea' },
37
+ { keywords: ['DOMICILIACION DE PAGOS', 'DOMICILIACION'], account: '11000', name: 'Domiciliación' },
38
+
39
+ // UBII / merchant clearing (INM.OB family) — see project memory project_ubii_clearing_account.md
40
+ { keywords: ['CREDITO INM.OB', 'INM.OB'], account: '18290', name: 'UBII Clearing (revisar emparejamiento cross-bank)' },
41
+
42
+ // Loans (líneas de crédito)
43
+ { keywords: ['DESEMBOLSO DE CREDITO'], account: '23100', name: 'Desembolso Crédito Bancario' },
44
+ { keywords: ['PAGO CREDITO', 'PAGO DE CREDITO', 'PAGO PRESTAMO'], account: '23100', name: 'Pago Crédito Bancario' },
45
+ { keywords: ['OP.INTERV', 'OPERACION INTERV', 'INTERV. CAMBIARIA', 'INTERVENCION CAMBIARIA'], account: '11500', name: 'Intervención cambiaria (USD compra/venta)' },
46
+
47
+ // Tax retentions
48
+ { keywords: ['N/D ISLR', 'RETENCION ISLR'], account: '21300', name: 'Retención ISLR' },
49
+ { keywords: ['SENIAT', 'IMPUESTO', 'IVA RETEN'], account: '21100', name: 'Retención IVA / SENIAT' },
50
+
51
+ // Statement fee
52
+ { keywords: ['EMISION DE ESTADO DE CUENTA', 'ESTADO DE CUENTA'], account: '67100', name: 'Comisión emisión estado de cuenta' },
53
+
54
+ // IVSS
55
+ { keywords: ['PAGO IVSS', 'IVSS'], account: '71300', name: 'Pago IVSS' },
56
+
57
+ // Checks
58
+ { keywords: ['CHEQUE PAGADO', 'CHQ', 'CHEQUE'], account: '11000', name: 'Cheque' },
59
+
60
+ // Outbound bank payments (PAGO A OTROS BANCOS / PAGO A TERCEROS)
61
+ { keywords: ['PAGO A OTROS BANCOS', 'PAGO A TERCEROS', 'PAGOS A OTROS BANCOS'], account: '11000', name: 'Pago a otros bancos' },
62
+
63
+ // INM.MB family (Internet/Mobile Banking credit movements, distinct from INM.OB)
64
+ { keywords: ['CREDITO INM.MB', 'INM.MB'], account: '11000', name: 'Movimiento Internet/Mobile Banking' },
65
+
66
+ // Loan opening fees
67
+ { keywords: ['GASTOS APERTURA CREDITOS', 'APERTURA CREDITO'], account: '67100', name: 'Gastos apertura crédito' },
33
68
  ];
34
69
 
35
70
  // Match a bank description against keywords
package/lib/bc-client.js CHANGED
@@ -2,6 +2,7 @@ import { logger } from '../utils/logger.js';
2
2
  import { getExpenseCategory } from '../config/expense-accounts.js';
3
3
  import { INCOME_GROUP_LABELS } from '../config/income-accounts.js';
4
4
  import { resolveStores } from '../config/company-config.js';
5
+ import { bcLimiter } from '../utils/rate-limiter.js';
5
6
 
6
7
  export class BCClient {
7
8
  constructor() {
@@ -19,6 +20,28 @@ export class BCClient {
19
20
  this.EXCHANGE_RATE_TTL = 15 * 60 * 1000; // 15 minutes
20
21
  }
21
22
 
23
+ /**
24
+ * Sanitize a string value before embedding it in an OData $filter expression.
25
+ *
26
+ * OData string literals are delimited by single quotes. An attacker who
27
+ * controls a filter parameter (e.g. accountNo, vendorNo, postingDate) could
28
+ * inject arbitrary filter logic by inserting a single quote. Doubling every
29
+ * single quote is the standard OData escape sequence (identical to SQL).
30
+ *
31
+ * Usage inside a $filter template:
32
+ * `accountNo eq '${this._sanitizeOData(args.accountNo)}'`
33
+ *
34
+ * DOES NOT protect numeric / date literals — those should be validated with
35
+ * Number.isFinite() or a date regex before use.
36
+ *
37
+ * @param {string} value
38
+ * @returns {string}
39
+ */
40
+ _sanitizeOData(value) {
41
+ if (value == null) return '';
42
+ return String(value).replace(/'/g, "''");
43
+ }
44
+
22
45
  _cacheKey(prefix, ...parts) {
23
46
  return `${prefix}:${parts.join(':')}`;
24
47
  }
@@ -93,6 +116,7 @@ export class BCClient {
93
116
  }
94
117
 
95
118
  async apiCall(url) {
119
+ bcLimiter.recordCall(); // throws if rate limit exceeded
96
120
  const token = await this.getToken();
97
121
  logger.debug(`API call: ${url}`);
98
122
 
@@ -130,13 +154,20 @@ export class BCClient {
130
154
  }
131
155
  }
132
156
 
133
- async apiCallAllPages(url) {
157
+ async apiCallAllPages(url, maxPages = 20) {
134
158
  const token = await this.getToken();
135
159
  let allResults = [];
136
160
  let nextUrl = url;
161
+ let pageCount = 0;
137
162
 
138
163
  while (nextUrl) {
139
- logger.debug(`API call (paged): ${nextUrl}`);
164
+ if (pageCount >= maxPages) {
165
+ logger.warn(`apiCallAllPages: maxPages (${maxPages}) reached — results may be truncated. Set maxPages higher if needed.`);
166
+ allResults._truncated = true;
167
+ break;
168
+ }
169
+ bcLimiter.recordCall();
170
+ logger.debug(`API call (paged ${pageCount + 1}/${maxPages}): ${nextUrl}`);
140
171
  const response = await this.fetchWithRetry(nextUrl, {
141
172
  headers: { Authorization: `Bearer ${token}` },
142
173
  });
@@ -149,6 +180,7 @@ export class BCClient {
149
180
  const data = await response.json();
150
181
  allResults = allResults.concat(data.value || []);
151
182
  nextUrl = data['@odata.nextLink'] || null;
183
+ pageCount++;
152
184
  }
153
185
 
154
186
  return allResults;
@@ -240,13 +272,20 @@ export class BCClient {
240
272
  return data.value || data;
241
273
  }
242
274
 
243
- async odataCallAllPages(url) {
275
+ async odataCallAllPages(url, maxPages = 20) {
244
276
  const token = await this.getToken();
245
277
  let allResults = [];
246
278
  let nextUrl = url;
279
+ let pageCount = 0;
247
280
 
248
281
  while (nextUrl) {
249
- logger.debug(`OData call (paged): ${nextUrl}`);
282
+ if (pageCount >= maxPages) {
283
+ logger.warn(`odataCallAllPages: maxPages (${maxPages}) reached — results may be truncated.`);
284
+ allResults._truncated = true;
285
+ break;
286
+ }
287
+ bcLimiter.recordCall();
288
+ logger.debug(`OData call (paged ${pageCount + 1}/${maxPages}): ${nextUrl}`);
250
289
  const response = await this.fetchWithRetry(nextUrl, {
251
290
  headers: { Authorization: `Bearer ${token}` },
252
291
  });
@@ -259,6 +298,7 @@ export class BCClient {
259
298
  const data = await response.json();
260
299
  allResults = allResults.concat(data.value || []);
261
300
  nextUrl = data['@odata.nextLink'] || null;
301
+ pageCount++;
262
302
  }
263
303
 
264
304
  return allResults;
@@ -266,8 +306,10 @@ export class BCClient {
266
306
 
267
307
  // Fetch General Ledger Entries for a specific account range
268
308
  async getGLEntries(companyId, startDate, endDate, accountMin, accountMax) {
309
+ const sd = this._sanitizeOData(startDate);
310
+ const ed = this._sanitizeOData(endDate);
269
311
  const url = this.buildApiUrl(companyId, 'generalLedgerEntries', {
270
- $filter: `postingDate ge ${startDate} and postingDate le ${endDate} and accountNumber ge '${accountMin}' and accountNumber le '${accountMax}'`,
312
+ $filter: `postingDate ge ${sd} and postingDate le ${ed} and accountNumber ge '${accountMin}' and accountNumber le '${accountMax}'`,
271
313
  $select: 'entryNumber,postingDate,documentNumber,accountNumber,description,debitAmount,creditAmount',
272
314
  $orderby: 'postingDate desc',
273
315
  });
@@ -509,13 +551,13 @@ export class BCClient {
509
551
  async getDetailedGLEntries(companyId, filters = {}) {
510
552
  const conditions = [];
511
553
 
512
- if (filters.startDate) conditions.push(`postingDate ge ${filters.startDate}`);
513
- if (filters.endDate) conditions.push(`postingDate le ${filters.endDate}`);
554
+ if (filters.startDate) conditions.push(`postingDate ge ${this._sanitizeOData(filters.startDate)}`);
555
+ if (filters.endDate) conditions.push(`postingDate le ${this._sanitizeOData(filters.endDate)}`);
514
556
  if (filters.accountNumber) {
515
- conditions.push(`accountNumber eq '${filters.accountNumber}'`);
557
+ conditions.push(`accountNumber eq '${this._sanitizeOData(filters.accountNumber)}'`);
516
558
  } else if (filters.accountMin && filters.accountMax) {
517
- conditions.push(`accountNumber ge '${filters.accountMin}'`);
518
- conditions.push(`accountNumber le '${filters.accountMax}'`);
559
+ conditions.push(`accountNumber ge '${this._sanitizeOData(filters.accountMin)}'`);
560
+ conditions.push(`accountNumber le '${this._sanitizeOData(filters.accountMax)}'`);
519
561
  }
520
562
 
521
563
  const params = {
@@ -533,8 +575,10 @@ export class BCClient {
533
575
  // ── Sales Analysis Methods ──────────────────────────────────────
534
576
 
535
577
  async getSalesInvoicesExpanded(companyId, startDate, endDate) {
578
+ const sd = this._sanitizeOData(startDate);
579
+ const ed = this._sanitizeOData(endDate);
536
580
  const url = this.buildApiUrl(companyId, 'salesInvoices', {
537
- $filter: `postingDate ge ${startDate} and postingDate le ${endDate}`,
581
+ $filter: `postingDate ge ${sd} and postingDate le ${ed}`,
538
582
  $orderby: 'postingDate desc',
539
583
  $expand: 'salesInvoiceLines',
540
584
  });
@@ -567,7 +611,23 @@ export class BCClient {
567
611
 
568
612
  async getItemsCatalog(companyId) {
569
613
  const url = this.buildApiUrl(companyId, 'items', {
570
- $select: 'id,number,displayName,type,unitPrice,unitCost,itemCategoryCode,lastDirectCost',
614
+ $select: 'id,number,displayName,type,unitPrice,unitCost,itemCategoryCode',
615
+ });
616
+ return this.apiCallAllPages(url);
617
+ }
618
+
619
+ // Fetch Sale-type Item Ledger Entries for one or more items in a date range.
620
+ // Returns the raw per-entry rows from BC (signs unchanged: quantity & costAmountActual negative).
621
+ async getItemLedgerSaleEntries(companyId, { items, startDate, endDate }) {
622
+ if (!items || items.length === 0) return [];
623
+ const itemFilter = items.length === 1
624
+ ? `itemNumber eq '${items[0]}'`
625
+ : `(${items.map((i) => `itemNumber eq '${i}'`).join(' or ')})`;
626
+
627
+ const url = this.buildApiUrl(companyId, 'itemLedgerEntries', {
628
+ $filter: `${itemFilter} and entryType eq 'Sale' and postingDate ge ${startDate} and postingDate le ${endDate}`,
629
+ $select: 'entryNumber,itemNumber,postingDate,entryType,documentNumber,description,quantity,salesAmountActual,costAmountActual',
630
+ $orderby: 'itemNumber,postingDate',
571
631
  });
572
632
  return this.apiCallAllPages(url);
573
633
  }
@@ -619,7 +679,7 @@ export class BCClient {
619
679
  const item = itemsMap[itemNo];
620
680
  if (item) {
621
681
  line.itemCategoryCode = item.itemCategoryCode;
622
- line.unitCostUSD = item.lastDirectCost || item.unitCost || 0;
682
+ line.unitCostUSD = item.unitCost || 0;
623
683
  line.displayName = item.displayName || line.description;
624
684
  }
625
685
  if (!line.displayName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullqueso/mcp-bc-gastos",
3
- "version": "1.17.0",
3
+ "version": "1.23.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": {
@@ -23,7 +23,8 @@
23
23
  "start": "node server.js",
24
24
  "dev": "nodemon server.js",
25
25
  "test-connection": "node test-connection.js",
26
- "test-tools": "node test-tools.js"
26
+ "test-tools": "node test-tools.js",
27
+ "test-item-sales-detail": "node tests/test-item-sales-detail.js"
27
28
  },
28
29
  "keywords": [
29
30
  "mcp",
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Read-only diagnostic for any (store, month) bank closing snapshot.
3
+ //
4
+ // Usage:
5
+ // node scripts/diagnose-closing.js --store FQ01 --month 2025-12
6
+ // node scripts/diagnose-closing.js -s FQ28 -m 2026-01 --bank FQ28-MN0001
7
+ //
8
+ // Hits BC OData/Beta API read-only. Writes a JSON snapshot to /tmp and
9
+ // prints a summary to stderr.
10
+
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname, join } from 'path';
13
+ import { existsSync, writeFileSync } from 'fs';
14
+ import dotenv from 'dotenv';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const envPath = join(__dirname, '..', '.env');
18
+ if (existsSync(envPath)) dotenv.config({ path: envPath });
19
+
20
+ const { BCClient } = await import('../lib/bc-client.js');
21
+ const { handleReconciliationStatus } = await import('../tools/auditoria/reconciliation-status.js');
22
+ const { handleBankReconciliationReport } = await import('../tools/auditoria/bank-reconciliation-report.js');
23
+ const { handleOpenReceivables } = await import('../tools/cobranzas/open-receivables.js');
24
+ const { handleOpenPayables } = await import('../tools/cobranzas/open-payables.js');
25
+ const { resolveStores } = await import('../config/company-config.js');
26
+
27
+ // ── CLI args ───────────────────────────────────────────────
28
+ function parseArgs(argv) {
29
+ const out = {};
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ const next = argv[i + 1];
33
+ if (a === '--store' || a === '-s') { out.store = next; i++; }
34
+ else if (a === '--month' || a === '-m') { out.month = next; i++; }
35
+ else if (a === '--bank' || a === '-b') { out.bank = next; i++; }
36
+ else if (a === '--help' || a === '-h') { out.help = true; }
37
+ }
38
+ return out;
39
+ }
40
+ const args = parseArgs(process.argv);
41
+ if (args.help || !args.store || !args.month) {
42
+ console.error('Usage: node scripts/diagnose-closing.js --store FQ01 --month 2025-12 [--bank FQ01-MN0001]');
43
+ process.exit(args.help ? 0 : 2);
44
+ }
45
+
46
+ const STORE = args.store;
47
+ const MONTH = args.month;
48
+ const MONTH_END = lastDayOfMonth(MONTH);
49
+ const BANK_FILTER = args.bank || null;
50
+
51
+ const bc = new BCClient();
52
+ const out = { store: STORE, month: MONTH, bank_filter: BANK_FILTER, generated_at: new Date().toISOString() };
53
+
54
+ console.error(`\n=== Diagnostic ${STORE} ${MONTH} ${BANK_FILTER ? '/' + BANK_FILTER : ''} ===\n`);
55
+
56
+ // 1. Reconciliation status
57
+ try {
58
+ console.error('[1/4] reconciliation_status…');
59
+ out.reconciliation_status = await handleReconciliationStatus(bc, { store: STORE });
60
+ const recs = out.reconciliation_status.reconciliations || [];
61
+ const monthRecs = recs.filter((r) => (r.statement_date || '').startsWith(MONTH));
62
+ console.error(` → ${recs.length} open reconciliations total; ${monthRecs.length} for ${MONTH}`);
63
+ for (const r of monthRecs) {
64
+ if (BANK_FILTER && r.bank_account_no !== BANK_FILTER) continue;
65
+ console.error(` ${r.bank_account_no} #${r.statement_no} date=${r.statement_date} matched=${r.matched_lines}/${r.total_lines} ending=${r.statement_ending_balance}`);
66
+ }
67
+ out.month_reconciliations = monthRecs;
68
+ } catch (err) {
69
+ console.error(' ✗', err.message);
70
+ out.reconciliation_status_error = err.message;
71
+ }
72
+
73
+ // 2. Bank reconciliation report (per bank or filter)
74
+ try {
75
+ console.error('\n[2/4] bank_reconciliation_report…');
76
+ const targets = BANK_FILTER
77
+ ? [BANK_FILTER]
78
+ : (out.month_reconciliations || []).map((r) => r.bank_account_no);
79
+ out.bank_reports = {};
80
+ for (const bank of targets) {
81
+ const r = await handleBankReconciliationReport(bc, {
82
+ store: STORE, bank_account: bank, month: MONTH, include_suggestions: true,
83
+ });
84
+ out.bank_reports[bank] = {
85
+ statement_no: r.statement_no,
86
+ statement_date: r.statement_date,
87
+ balance_last_statement: r.balance_last_statement,
88
+ statement_ending_balance: r.statement_ending_balance,
89
+ unmatched_debits_count: r.unmatched_debits?.count,
90
+ unmatched_debits_total: r.unmatched_debits?.total_amount,
91
+ unmatched_credits_count: r.unmatched_credits?.count,
92
+ unmatched_credits_total: r.unmatched_credits?.total_amount,
93
+ match_rate_pct: r.unmatched_summary?.match_rate_pct,
94
+ suggestions_summary: r.journal_suggestions?.summary,
95
+ };
96
+ console.error(` ${bank}: end=${r.statement_ending_balance} unmD=${r.unmatched_debits?.count} unmC=${r.unmatched_credits?.count} match=${r.unmatched_summary?.match_rate_pct}%`);
97
+ }
98
+ } catch (err) {
99
+ console.error(' ✗', err.message);
100
+ out.bank_reports_error = err.message;
101
+ }
102
+
103
+ // 3. Open receivables / payables as of month_end
104
+ try {
105
+ console.error('\n[3/4] open_receivables + open_payables…');
106
+ const [ar, ap] = await Promise.all([
107
+ handleOpenReceivables(bc, { store: STORE, as_of_date: MONTH_END }),
108
+ handleOpenPayables(bc, { store: STORE, as_of_date: MONTH_END }),
109
+ ]);
110
+ out.ar_summary = { customers: ar.receivables_by_customer?.length, aging: ar.aging_summary };
111
+ out.ap_summary = { vendors: ap.payables_by_vendor?.length, aging: ap.aging_summary };
112
+ console.error(` AR: ${ar.receivables_by_customer?.length} customers, total ${ar.aging_summary?.total} USD`);
113
+ console.error(` AP: ${ap.payables_by_vendor?.length} vendors, total ${ap.aging_summary?.total} USD`);
114
+ } catch (err) {
115
+ console.error(' ✗', err.message);
116
+ out.arap_error = err.message;
117
+ }
118
+
119
+ // 4. Multi-Payment drafts (Gestion BC) — Transferred status
120
+ try {
121
+ console.error('\n[4/4] multi-payment drafts (Transferred)…');
122
+ const { handleDraftPayables, handleDraftReceivables } = await import('../tools/multi-payment/index.js');
123
+ const [dp, dr] = await Promise.all([
124
+ handleDraftPayables(bc, { store: STORE }),
125
+ handleDraftReceivables(bc, { store: STORE }),
126
+ ]);
127
+ // Count Transferred drafts
128
+ const tp = (dp?.drafts_by_status?.Transferred || dp?.results || []).length;
129
+ const tr = (dr?.drafts_by_status?.Transferred || dr?.results || []).length;
130
+ out.mp_drafts = { transferred_payables: tp, transferred_receivables: tr };
131
+ console.error(` Transferred drafts → payables: ${tp}, receivables: ${tr}`);
132
+ } catch (err) {
133
+ console.error(' ✗', err.message);
134
+ out.mp_error = err.message;
135
+ }
136
+
137
+ const outPath = `/tmp/diagnose-${STORE}-${MONTH}.json`;
138
+ writeFileSync(outPath, JSON.stringify(out, null, 2));
139
+ console.error(`\n✓ Snapshot → ${outPath}\n`);
140
+
141
+ function lastDayOfMonth(yyyyMm) {
142
+ const [y, m] = yyyyMm.split('-').map(Number);
143
+ return new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10);
144
+ }