@badgie/crm-cli 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.
@@ -4,6 +4,96 @@ import { getAuthedClient } from '../core/supabase.js';
4
4
  import { commonListOptions, output, parseColumns, parseLimit } from '../core/format.js';
5
5
  import { callAppApi, postAppApiMultipart } from '../core/http.js';
6
6
  import { maybeResolve } from '../core/resolve.js';
7
+ function parseIsoDateOption(raw, flag, required = false) {
8
+ if (raw == null || raw === '') {
9
+ if (required)
10
+ throw new Error(`${flag} YYYY-MM-DD is required`);
11
+ return undefined;
12
+ }
13
+ if (typeof raw !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
14
+ throw new Error(`${flag} must be YYYY-MM-DD`);
15
+ }
16
+ return raw;
17
+ }
18
+ function parseIntegerOption(raw, flag, min, max, required = false) {
19
+ if (raw == null || raw === '') {
20
+ if (required)
21
+ throw new Error(`${flag} is required`);
22
+ return undefined;
23
+ }
24
+ const n = typeof raw === 'number' ? raw : Number(raw);
25
+ if (!Number.isInteger(n) || n < min || n > max) {
26
+ throw new Error(`${flag} must be an integer between ${min} and ${max}`);
27
+ }
28
+ return n;
29
+ }
30
+ function parseAmountOption(raw, flag, required = false) {
31
+ if (raw == null || raw === '') {
32
+ if (required)
33
+ throw new Error(`${flag} is required`);
34
+ return undefined;
35
+ }
36
+ const n = Number(raw);
37
+ if (!Number.isFinite(n) || n < 0)
38
+ throw new Error(`${flag} must be a non-negative number`);
39
+ return n;
40
+ }
41
+ function currentPeriod() {
42
+ const d = new Date();
43
+ return { year: d.getFullYear(), month: d.getMonth() + 1, quarter: Math.ceil((d.getMonth() + 1) / 3) };
44
+ }
45
+ function appendQuery(path, params) {
46
+ const qs = new URLSearchParams();
47
+ for (const [key, value] of Object.entries(params)) {
48
+ if (value !== undefined)
49
+ qs.set(key, String(value));
50
+ }
51
+ const suffix = qs.toString();
52
+ return suffix ? `${path}?${suffix}` : path;
53
+ }
54
+ function csvEscape(value) {
55
+ const s = value == null ? '' : String(value);
56
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
57
+ }
58
+ function printCsv(headers, row) {
59
+ console.log(headers.map(csvEscape).join(','));
60
+ console.log(row.map(csvEscape).join(','));
61
+ }
62
+ function moneyCsv(value) {
63
+ return (Math.round(value * 100) / 100).toFixed(2);
64
+ }
65
+ function parseDate(raw) {
66
+ if (!raw)
67
+ return null;
68
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
69
+ if (!match)
70
+ return null;
71
+ const d = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
72
+ return Number.isNaN(d.getTime()) ? null : d;
73
+ }
74
+ function isoDate(d) {
75
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
76
+ }
77
+ function addMonths(d, months) {
78
+ return new Date(d.getFullYear(), d.getMonth() + months, d.getDate());
79
+ }
80
+ function startOfDay(d) {
81
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
82
+ }
83
+ // Paginates a Supabase query in 1000-row chunks. Aging/forecast aggregations need every row;
84
+ // the default 1000-row cap silently truncates totals on tenants with high volume.
85
+ async function fetchAllRows(builder, chunkSize = 1000) {
86
+ const out = [];
87
+ for (let from = 0;; from += chunkSize) {
88
+ const { data, error } = await builder().range(from, from + chunkSize - 1);
89
+ if (error)
90
+ throw error;
91
+ const rows = (data ?? []);
92
+ out.push(...rows);
93
+ if (rows.length < chunkSize)
94
+ return out;
95
+ }
96
+ }
7
97
  // Invoices
8
98
  async function listInvoices(_a, opts) {
9
99
  const { client } = await getAuthedClient();
@@ -325,6 +415,443 @@ async function fxUsdEur(_a, opts) {
325
415
  const res = await callAppApi(`/api/finance/fx-usd-eur?${params.toString()}`);
326
416
  output(res, { pretty: !!opts.pretty });
327
417
  }
418
+ // Finance OS
419
+ async function financeHealth(_args, opts) {
420
+ const res = await callAppApi('/api/finance/health');
421
+ output(opts.criticalOnly ? { issues: res.issues.filter((i) => i.severity === 'critical') } : res, {
422
+ pretty: !!opts.pretty,
423
+ });
424
+ }
425
+ async function financeTaxSummary(_args, opts) {
426
+ const now = currentPeriod();
427
+ const year = parseIntegerOption(opts.year, '--year', 2020, 2100) ?? now.year;
428
+ const quarter = parseIntegerOption(opts.quarter, '--quarter', 1, 4) ?? now.quarter;
429
+ const res = await callAppApi(appendQuery('/api/finance/tax-summary', { year, quarter }));
430
+ const s = res.summary;
431
+ if (opts.csv) {
432
+ printCsv([
433
+ 'periodo',
434
+ 'base_emitida',
435
+ 'base_recibida',
436
+ 'iva_repercutido',
437
+ 'iva_soportado',
438
+ 'iva_neto',
439
+ 'retenciones_emitidas',
440
+ 'retenciones_recibidas',
441
+ 'impacto_estimado',
442
+ 'facturas',
443
+ ], [
444
+ s.periodLabel,
445
+ moneyCsv(s.issuedBase),
446
+ moneyCsv(s.receivedBase),
447
+ moneyCsv(s.vatCharged),
448
+ moneyCsv(s.vatDeductible),
449
+ moneyCsv(s.vatNet),
450
+ moneyCsv(s.withholdingOnIssued),
451
+ moneyCsv(s.withholdingOnReceived),
452
+ moneyCsv(s.estimatedTaxCashImpact),
453
+ s.invoiceCount,
454
+ ]);
455
+ return;
456
+ }
457
+ output(s, { pretty: !!opts.pretty });
458
+ }
459
+ function emptyAgingBucket() {
460
+ return { count: 0, amount: 0 };
461
+ }
462
+ function emptyAgingSummary() {
463
+ return {
464
+ overdue: emptyAgingBucket(),
465
+ due_0_7: emptyAgingBucket(),
466
+ due_8_30: emptyAgingBucket(),
467
+ due_31_60: emptyAgingBucket(),
468
+ due_60_plus: emptyAgingBucket(),
469
+ no_due: emptyAgingBucket(),
470
+ };
471
+ }
472
+ function agingBucketKey(dueDate, referenceDate) {
473
+ if (!dueDate)
474
+ return 'no_due';
475
+ const diffDays = Math.floor((startOfDay(dueDate).getTime() - startOfDay(referenceDate).getTime()) / 86_400_000);
476
+ if (diffDays < 0)
477
+ return 'overdue';
478
+ if (diffDays <= 7)
479
+ return 'due_0_7';
480
+ if (diffDays <= 30)
481
+ return 'due_8_30';
482
+ if (diffDays <= 60)
483
+ return 'due_31_60';
484
+ return 'due_60_plus';
485
+ }
486
+ function computeAging(invoices, referenceDate) {
487
+ const receivables = emptyAgingSummary();
488
+ const payables = emptyAgingSummary();
489
+ for (const inv of invoices) {
490
+ const status = String(inv.status ?? '');
491
+ const isReceivable = inv.type === 'emitida' && status !== 'pagada' && status !== 'anulada';
492
+ const isPayable = inv.type === 'recibida' && status !== 'pagada' && status !== 'anulada' && status !== 'borrador';
493
+ if (!isReceivable && !isPayable)
494
+ continue;
495
+ const bucket = agingBucketKey(parseDate(inv.due_date) ?? parseDate(inv.invoice_date), referenceDate);
496
+ const amount = Math.abs(Number(inv.total_amount) || 0);
497
+ const target = isReceivable ? receivables : payables;
498
+ target[bucket].count += 1;
499
+ target[bucket].amount += amount;
500
+ }
501
+ const sum = (summary) => Object.values(summary).reduce((total, bucket) => total + bucket.amount, 0);
502
+ return {
503
+ referenceDate: isoDate(referenceDate),
504
+ receivables,
505
+ payables,
506
+ totals: {
507
+ receivables: sum(receivables),
508
+ payables: sum(payables),
509
+ overdueReceivables: receivables.overdue.amount,
510
+ overduePayables: payables.overdue.amount,
511
+ },
512
+ };
513
+ }
514
+ async function financeAging(_args, opts) {
515
+ const referenceRaw = parseIsoDateOption(opts.date, '--date');
516
+ const referenceDate = referenceRaw ? parseDate(referenceRaw) : new Date();
517
+ if (!referenceDate)
518
+ throw new Error('--date must be YYYY-MM-DD');
519
+ const { client } = await getAuthedClient();
520
+ const invoices = await fetchAllRows(() => client.from('invoices').select('id, type, status, invoice_date, due_date, total_amount'));
521
+ output(computeAging(invoices, referenceDate), { pretty: !!opts.pretty });
522
+ }
523
+ function sumLatestBankBalances(movements) {
524
+ const byAccount = new Map();
525
+ for (const m of movements) {
526
+ if ((m.bank_account?.account_type ?? 'bank') !== 'bank')
527
+ continue;
528
+ const list = byAccount.get(m.bank_account_id) ?? [];
529
+ list.push(m);
530
+ byAccount.set(m.bank_account_id, list);
531
+ }
532
+ if (byAccount.size === 0)
533
+ return null;
534
+ let total = 0;
535
+ let foundAny = false;
536
+ for (const movs of byAccount.values()) {
537
+ const sorted = [...movs].sort((a, b) => {
538
+ const c = a.movement_date.localeCompare(b.movement_date);
539
+ return c !== 0 ? c : a.id.localeCompare(b.id);
540
+ });
541
+ for (let i = sorted.length - 1; i >= 0; i--) {
542
+ const balance = Number(sorted[i].balance);
543
+ if (Number.isFinite(balance)) {
544
+ total += balance;
545
+ foundAny = true;
546
+ break;
547
+ }
548
+ }
549
+ }
550
+ return foundAny ? total : null;
551
+ }
552
+ function profileOf(client) {
553
+ const raw = client.client_finance_profiles;
554
+ if (!raw)
555
+ return null;
556
+ return Array.isArray(raw) ? raw[0] ?? null : raw;
557
+ }
558
+ function monthlyRecurringRevenue(clients) {
559
+ return clients.reduce((sum, client) => {
560
+ if (client.show_in_finance === false)
561
+ return sum;
562
+ const profile = profileOf(client);
563
+ if (!profile || profile.plan_membership !== 'active')
564
+ return sum;
565
+ const amount = Number(profile.billing_price_amount) || 0;
566
+ if (amount <= 0)
567
+ return sum;
568
+ if (profile.billing_plan_interval === 'annual')
569
+ return sum + amount / 12;
570
+ if (profile.billing_plan_interval === 'monthly')
571
+ return sum + amount;
572
+ return sum;
573
+ }, 0);
574
+ }
575
+ function isPendingIncome(inv) {
576
+ return inv.type === 'emitida' && inv.status !== 'pagada' && inv.status !== 'anulada';
577
+ }
578
+ function isPendingExpense(inv) {
579
+ return inv.type === 'recibida' && inv.status !== 'pagada' && inv.status !== 'anulada' && inv.status !== 'borrador';
580
+ }
581
+ function nextOccurrence(date, recurrence) {
582
+ if (recurrence === 'monthly')
583
+ return addMonths(date, 1);
584
+ if (recurrence === 'quarterly')
585
+ return addMonths(date, 3);
586
+ if (recurrence === 'annual')
587
+ return addMonths(date, 12);
588
+ return addMonths(date, 1200);
589
+ }
590
+ function sumForecastItems(items, type, from, to) {
591
+ let total = 0;
592
+ for (const item of items) {
593
+ if (item.status !== 'active' || item.type !== type)
594
+ continue;
595
+ const start = parseDate(item.next_date);
596
+ if (!start)
597
+ continue;
598
+ const end = parseDate(item.end_date);
599
+ let cursor = start;
600
+ while (cursor <= to && (!end || cursor <= end)) {
601
+ if (cursor >= from)
602
+ total += Math.abs(Number(item.amount) || 0);
603
+ if (item.recurrence === 'one_off')
604
+ break;
605
+ cursor = nextOccurrence(cursor, item.recurrence);
606
+ }
607
+ }
608
+ return total;
609
+ }
610
+ function sumInvoiceCash(invoices, type, from, to) {
611
+ return invoices.reduce((sum, inv) => {
612
+ const include = type === 'income' ? isPendingIncome(inv) : isPendingExpense(inv);
613
+ if (!include)
614
+ return sum;
615
+ const due = parseDate(inv.due_date) ?? parseDate(inv.invoice_date);
616
+ if (!due || due < from || due > to)
617
+ return sum;
618
+ return sum + Math.abs(Number(inv.total_amount) || 0);
619
+ }, 0);
620
+ }
621
+ function computeCashForecast({ invoices, bankMovements, forecastItems, clients, referenceDate, }) {
622
+ const startCash = sumLatestBankBalances(bankMovements);
623
+ const mrr = monthlyRecurringRevenue(clients);
624
+ const start = startOfDay(referenceDate);
625
+ const horizons = [30, 60, 90];
626
+ const points = horizons.map((horizonDays) => {
627
+ const end = new Date(start);
628
+ end.setDate(end.getDate() + horizonDays);
629
+ const pendingIncome = sumInvoiceCash(invoices, 'income', start, end);
630
+ const pendingExpenses = sumInvoiceCash(invoices, 'expense', start, end);
631
+ const manualIncome = sumForecastItems(forecastItems, 'income', start, end);
632
+ const manualExpenses = sumForecastItems(forecastItems, 'expense', start, end);
633
+ const recurringIncome = Math.round(mrr * (horizonDays / 30) * 100) / 100;
634
+ const expectedIncome = pendingIncome + manualIncome + recurringIncome;
635
+ const expectedExpenses = pendingExpenses + manualExpenses;
636
+ const netChange = expectedIncome - expectedExpenses;
637
+ return {
638
+ horizonDays,
639
+ expectedIncome,
640
+ expectedExpenses,
641
+ netChange,
642
+ projectedCash: startCash === null ? null : startCash + netChange,
643
+ };
644
+ });
645
+ const p90 = points.find((p) => p.horizonDays === 90) ?? points[points.length - 1];
646
+ const avgDailyBurn = p90 && p90.netChange < 0 ? Math.abs(p90.netChange) / p90.horizonDays : 0;
647
+ return {
648
+ startCash,
649
+ referenceDate: isoDate(start),
650
+ mrr,
651
+ points,
652
+ runwayDays: startCash !== null && avgDailyBurn > 0 ? Math.floor(startCash / avgDailyBurn) : null,
653
+ nextReceivables: sumInvoiceCash(invoices, 'income', start, addMonths(start, 2)),
654
+ nextPayables: sumInvoiceCash(invoices, 'expense', start, addMonths(start, 2)),
655
+ };
656
+ }
657
+ async function loadForecastInputs() {
658
+ const { client } = await getAuthedClient();
659
+ const [invoices, bankMovements, forecastItems, clients] = await Promise.all([
660
+ fetchAllRows(() => client.from('invoices').select('id, type, status, invoice_date, due_date, total_amount')),
661
+ fetchAllRows(() => client.from('bank_movements').select('id, bank_account_id, movement_date, balance, bank_account:bank_accounts(account_type)')),
662
+ fetchAllRows(() => client
663
+ .from('finance_forecast_items')
664
+ .select('*')
665
+ .order('status', { ascending: true })
666
+ .order('next_date', { ascending: true })),
667
+ fetchAllRows(() => client.from('clients').select('show_in_finance, client_finance_profiles (billing_price_amount, billing_plan_interval, plan_membership)')),
668
+ ]);
669
+ return { invoices, bankMovements, forecastItems, clients };
670
+ }
671
+ async function financeForecastSummary(_args, opts) {
672
+ const referenceRaw = parseIsoDateOption(opts.date, '--date');
673
+ const referenceDate = referenceRaw ? parseDate(referenceRaw) : new Date();
674
+ if (!referenceDate)
675
+ throw new Error('--date must be YYYY-MM-DD');
676
+ const inputs = await loadForecastInputs();
677
+ output(computeCashForecast({ ...inputs, referenceDate }), { pretty: !!opts.pretty });
678
+ }
679
+ async function financeForecastList(_args, opts) {
680
+ const res = await callAppApi('/api/finance/forecast-items');
681
+ let rows = res.forecastItems;
682
+ if (opts.activeOnly)
683
+ rows = rows.filter((item) => item.status === 'active');
684
+ if (opts.pausedOnly)
685
+ rows = rows.filter((item) => item.status === 'paused');
686
+ if (typeof opts.type === 'string')
687
+ rows = rows.filter((item) => item.type === opts.type);
688
+ rows = rows.slice(0, parseLimit(opts));
689
+ output(rows, { pretty: !!opts.pretty, columns: parseColumns(opts) });
690
+ }
691
+ function forecastPayloadFromOpts(opts, mode) {
692
+ const payload = {};
693
+ if (opts.type !== undefined) {
694
+ if (opts.type !== 'income' && opts.type !== 'expense')
695
+ throw new Error('--type must be income|expense');
696
+ payload.type = opts.type;
697
+ }
698
+ if (typeof opts.name === 'string')
699
+ payload.name = opts.name;
700
+ const amount = parseAmountOption(opts.amount, '--amount', mode === 'insert');
701
+ if (amount !== undefined)
702
+ payload.amount = amount;
703
+ if (opts.recurrence !== undefined) {
704
+ if (!['one_off', 'monthly', 'quarterly', 'annual'].includes(String(opts.recurrence))) {
705
+ throw new Error('--recurrence must be one_off|monthly|quarterly|annual');
706
+ }
707
+ payload.recurrence = opts.recurrence;
708
+ }
709
+ const nextDate = parseIsoDateOption(opts.nextDate, '--next-date', mode === 'insert');
710
+ if (nextDate)
711
+ payload.next_date = nextDate;
712
+ const endDate = parseIsoDateOption(opts.endDate, '--end-date');
713
+ if (endDate)
714
+ payload.end_date = endDate;
715
+ if (opts.clearEndDate)
716
+ payload.end_date = null;
717
+ if (opts.status !== undefined) {
718
+ if (opts.status !== 'active' && opts.status !== 'paused')
719
+ throw new Error('--status must be active|paused');
720
+ payload.status = opts.status;
721
+ }
722
+ if (typeof opts.counterparty === 'string')
723
+ payload.counterparty_name = opts.counterparty;
724
+ if (opts.clearCounterparty)
725
+ payload.counterparty_name = null;
726
+ if (typeof opts.category === 'string')
727
+ payload.category = opts.category;
728
+ if (opts.clearCategory)
729
+ payload.category = null;
730
+ if (typeof opts.notes === 'string')
731
+ payload.notes = opts.notes;
732
+ if (opts.clearNotes)
733
+ payload.notes = null;
734
+ if (typeof opts.currency === 'string')
735
+ payload.currency = opts.currency;
736
+ if (mode === 'insert') {
737
+ if (!payload.type)
738
+ throw new Error('--type income|expense is required');
739
+ if (!payload.name)
740
+ throw new Error('--name is required');
741
+ if (!payload.recurrence)
742
+ payload.recurrence = 'monthly';
743
+ }
744
+ return payload;
745
+ }
746
+ async function financeForecastAdd(_args, opts) {
747
+ const payload = forecastPayloadFromOpts(opts, 'insert');
748
+ const res = await callAppApi('/api/finance/forecast-items', {
749
+ method: 'POST',
750
+ body: JSON.stringify(payload),
751
+ });
752
+ output(res, { pretty: !!opts.pretty });
753
+ }
754
+ async function financeForecastUpdate(args, opts) {
755
+ if (!args.id)
756
+ throw new Error('Falta forecast item id');
757
+ const payload = { id: args.id, ...forecastPayloadFromOpts(opts, 'patch') };
758
+ const res = await callAppApi('/api/finance/forecast-items', {
759
+ method: 'PATCH',
760
+ body: JSON.stringify(payload),
761
+ });
762
+ output(res, { pretty: !!opts.pretty });
763
+ }
764
+ async function financeForecastPause(args, opts) {
765
+ if (!args.id)
766
+ throw new Error('Falta forecast item id');
767
+ const res = await callAppApi('/api/finance/forecast-items', {
768
+ method: 'PATCH',
769
+ body: JSON.stringify({ id: args.id, status: 'paused' }),
770
+ });
771
+ output(res, { pretty: !!opts.pretty });
772
+ }
773
+ async function financeForecastResume(args, opts) {
774
+ if (!args.id)
775
+ throw new Error('Falta forecast item id');
776
+ const res = await callAppApi('/api/finance/forecast-items', {
777
+ method: 'PATCH',
778
+ body: JSON.stringify({ id: args.id, status: 'active' }),
779
+ });
780
+ output(res, { pretty: !!opts.pretty });
781
+ }
782
+ async function financeForecastDelete(args, opts) {
783
+ if (!args.id)
784
+ throw new Error('Falta forecast item id');
785
+ const res = await callAppApi(`/api/finance/forecast-items?id=${encodeURIComponent(args.id)}`, {
786
+ method: 'DELETE',
787
+ });
788
+ output(res, { pretty: !!opts.pretty });
789
+ }
790
+ async function financeClosingsList(_args, opts) {
791
+ const year = parseIntegerOption(opts.year, '--year', 2020, 2100);
792
+ const month = parseIntegerOption(opts.month, '--month', 1, 12);
793
+ const res = await callAppApi(appendQuery('/api/finance/closings', { year, month }));
794
+ output(res.closings.slice(0, parseLimit(opts)), { pretty: !!opts.pretty, columns: parseColumns(opts) });
795
+ }
796
+ async function financeClosingClose(_args, opts) {
797
+ const year = parseIntegerOption(opts.year, '--year', 2020, 2100, true);
798
+ const month = parseIntegerOption(opts.month, '--month', 1, 12, true);
799
+ const res = await callAppApi('/api/finance/closings', {
800
+ method: 'POST',
801
+ body: JSON.stringify({
802
+ year,
803
+ month,
804
+ status: 'closed',
805
+ close: true,
806
+ notes: typeof opts.notes === 'string' ? opts.notes : null,
807
+ }),
808
+ });
809
+ output(res, { pretty: !!opts.pretty });
810
+ }
811
+ async function financeClosingReopen(_args, opts) {
812
+ const year = parseIntegerOption(opts.year, '--year', 2020, 2100, true);
813
+ const month = parseIntegerOption(opts.month, '--month', 1, 12, true);
814
+ const res = await callAppApi('/api/finance/closings', {
815
+ method: 'PATCH',
816
+ body: JSON.stringify({
817
+ year,
818
+ month,
819
+ status: 'open',
820
+ notes: typeof opts.notes === 'string' ? opts.notes : undefined,
821
+ }),
822
+ });
823
+ output(res, { pretty: !!opts.pretty });
824
+ }
825
+ async function financeControlSummary(_args, opts) {
826
+ const now = currentPeriod();
827
+ const year = parseIntegerOption(opts.year, '--year', 2020, 2100) ?? now.year;
828
+ const month = parseIntegerOption(opts.month, '--month', 1, 12) ?? now.month;
829
+ const quarter = parseIntegerOption(opts.quarter, '--quarter', 1, 4) ?? Math.ceil(month / 3);
830
+ const [health, tax, closings, forecastInputs] = await Promise.all([
831
+ callAppApi('/api/finance/health'),
832
+ callAppApi(appendQuery('/api/finance/tax-summary', { year, quarter })),
833
+ callAppApi(appendQuery('/api/finance/closings', { year, month })),
834
+ loadForecastInputs(),
835
+ ]);
836
+ const referenceRaw = parseIsoDateOption(opts.date, '--date');
837
+ const referenceDate = referenceRaw ? parseDate(referenceRaw) : new Date();
838
+ if (!referenceDate)
839
+ throw new Error('--date must be YYYY-MM-DD');
840
+ const aging = computeAging(forecastInputs.invoices, referenceDate);
841
+ const forecast = computeCashForecast({ ...forecastInputs, referenceDate });
842
+ output({
843
+ period: { year, month, quarter },
844
+ closing: closings.closings[0] ?? null,
845
+ health: {
846
+ totalIssues: health.issues.length,
847
+ criticalIssues: health.issues.filter((issue) => issue.severity === 'critical').length,
848
+ issues: health.issues,
849
+ },
850
+ tax: tax.summary,
851
+ aging,
852
+ forecast,
853
+ }, { pretty: !!opts.pretty });
854
+ }
328
855
  // Suppliers
329
856
  async function listSuppliers(_a, opts) {
330
857
  const { client } = await getAuthedClient();
@@ -568,6 +1095,213 @@ export const financeModule = {
568
1095
  examples: ['badgie-crm finance fx usd-eur --date 2026-04-15 --amount 1200 --pretty'],
569
1096
  handler: fxUsdEur,
570
1097
  },
1098
+ {
1099
+ path: ['finance', 'control', 'summary'],
1100
+ summary: 'Finance OS control summary: closing, health, tax, aging and cash forecast',
1101
+ description: 'Read-only cockpit for agents. Uses Finance OS APIs for health/tax/closings and Supabase reads for aging/forecast. Defaults to the current month and quarter.',
1102
+ tags: ['read', 'http'],
1103
+ options: [
1104
+ { flag: '--year <n>', description: 'period year (default current year)' },
1105
+ { flag: '--month <n>', description: 'period month 1-12 (default current month)' },
1106
+ { flag: '--quarter <n>', description: 'tax quarter 1-4 (default derived from month)' },
1107
+ { flag: '--date <yyyy-mm-dd>', description: 'reference date for aging/forecast (default today)' },
1108
+ { flag: '--pretty', description: 'human output' },
1109
+ ],
1110
+ examples: [
1111
+ 'badgie-crm finance control summary --pretty',
1112
+ 'badgie-crm finance control summary --year 2026 --month 4 --quarter 2',
1113
+ ],
1114
+ handler: financeControlSummary,
1115
+ },
1116
+ {
1117
+ path: ['finance', 'health'],
1118
+ summary: 'List Finance OS health issues',
1119
+ description: 'Returns actionable issues used by Finanzas → Control → Salud: unreconciled movements, missing categories/documents, duplicates, missing tax IDs and import warnings.',
1120
+ tags: ['read', 'http'],
1121
+ options: [
1122
+ { flag: '--critical-only', description: 'only severity=critical issues' },
1123
+ { flag: '--pretty', description: 'human output' },
1124
+ ],
1125
+ examples: [
1126
+ 'badgie-crm finance health --pretty',
1127
+ 'badgie-crm finance health --critical-only',
1128
+ ],
1129
+ handler: financeHealth,
1130
+ },
1131
+ {
1132
+ path: ['finance', 'aging'],
1133
+ summary: 'Compute receivables/payables aging buckets',
1134
+ description: 'Buckets pending issued/received invoices into overdue, 0-7, 8-30, 31-60 and 60+ days. Read-only and safe for agents.',
1135
+ tags: ['read'],
1136
+ options: [
1137
+ { flag: '--date <yyyy-mm-dd>', description: 'reference date (default today)' },
1138
+ { flag: '--pretty', description: 'human output' },
1139
+ ],
1140
+ examples: [
1141
+ 'badgie-crm finance aging --pretty',
1142
+ 'badgie-crm finance aging --date 2026-04-30',
1143
+ ],
1144
+ handler: financeAging,
1145
+ },
1146
+ {
1147
+ path: ['finance', 'tax-summary'],
1148
+ summary: 'Finance OS quarterly tax summary',
1149
+ description: 'Uses the same calculation as Control → Fiscal: issued/received base, VAT charged/deductible/net, withholdings and estimated cash impact.',
1150
+ tags: ['read', 'http'],
1151
+ options: [
1152
+ { flag: '--year <n>', description: 'tax year (default current year)' },
1153
+ { flag: '--quarter <n>', description: 'quarter 1-4 (default current quarter)' },
1154
+ { flag: '--csv', description: 'emit one CSV row instead of JSON' },
1155
+ { flag: '--pretty', description: 'human output' },
1156
+ ],
1157
+ examples: [
1158
+ 'badgie-crm finance tax-summary --year 2026 --quarter 2 --pretty',
1159
+ 'badgie-crm finance tax-summary --year 2026 --quarter 2 --csv > fiscal-q2.csv',
1160
+ ],
1161
+ handler: financeTaxSummary,
1162
+ },
1163
+ {
1164
+ path: ['finance', 'forecast', 'summary'],
1165
+ summary: 'Compute Finance OS cash forecast for 30/60/90 days',
1166
+ description: 'Combines latest bank balances, pending invoices, manual forecast items and active client MRR/ARR. Read-only.',
1167
+ tags: ['read'],
1168
+ options: [
1169
+ { flag: '--date <yyyy-mm-dd>', description: 'reference date (default today)' },
1170
+ { flag: '--pretty', description: 'human output' },
1171
+ ],
1172
+ examples: ['badgie-crm finance forecast summary --pretty'],
1173
+ handler: financeForecastSummary,
1174
+ },
1175
+ {
1176
+ path: ['finance', 'forecast', 'list'],
1177
+ summary: 'List manual Finance OS forecast items',
1178
+ tags: ['read', 'http'],
1179
+ options: [
1180
+ { flag: '--type <income|expense>', description: 'filter by type' },
1181
+ { flag: '--active-only', description: 'only active items' },
1182
+ { flag: '--paused-only', description: 'only paused items' },
1183
+ ...commonListOptions(),
1184
+ ],
1185
+ examples: ['badgie-crm finance forecast list --active-only --pretty'],
1186
+ handler: financeForecastList,
1187
+ },
1188
+ {
1189
+ path: ['finance', 'forecast', 'add'],
1190
+ summary: 'Create a manual Finance OS forecast item',
1191
+ description: 'Calls the Next app endpoint so audit log and closed-period guards are enforced.',
1192
+ tags: ['write', 'http'],
1193
+ options: [
1194
+ { flag: '--type <income|expense>', description: 'required' },
1195
+ { flag: '--name <text>', description: 'required concept/name' },
1196
+ { flag: '--amount <n>', description: 'required positive amount' },
1197
+ { flag: '--recurrence <one_off|monthly|quarterly|annual>', description: 'default monthly' },
1198
+ { flag: '--next-date <yyyy-mm-dd>', description: 'required next occurrence date' },
1199
+ { flag: '--end-date <yyyy-mm-dd>', description: 'optional end date' },
1200
+ { flag: '--counterparty <name>', description: 'optional counterparty' },
1201
+ { flag: '--category <slug>', description: 'optional category' },
1202
+ { flag: '--currency <code>', description: 'default EUR' },
1203
+ { flag: '--notes <text>', description: 'internal notes' },
1204
+ { flag: '--pretty', description: 'human output' },
1205
+ ],
1206
+ examples: [
1207
+ 'badgie-crm finance forecast add --type expense --name "Oficina" --amount 350 --recurrence monthly --next-date 2026-05-01 --pretty',
1208
+ 'badgie-crm finance forecast add --type income --name "Grant" --amount 5000 --recurrence one_off --next-date 2026-06-15',
1209
+ ],
1210
+ handler: financeForecastAdd,
1211
+ },
1212
+ {
1213
+ path: ['finance', 'forecast', 'update'],
1214
+ summary: 'Update a manual Finance OS forecast item',
1215
+ tags: ['write', 'http'],
1216
+ args: [{ name: 'id', required: true, description: 'finance_forecast_items.id' }],
1217
+ options: [
1218
+ { flag: '--type <income|expense>', description: 'set type' },
1219
+ { flag: '--name <text>', description: 'set name' },
1220
+ { flag: '--amount <n>', description: 'set amount' },
1221
+ { flag: '--recurrence <one_off|monthly|quarterly|annual>', description: 'set recurrence' },
1222
+ { flag: '--next-date <yyyy-mm-dd>', description: 'set next occurrence date' },
1223
+ { flag: '--end-date <yyyy-mm-dd>', description: 'set end date' },
1224
+ { flag: '--clear-end-date', description: 'clear end date' },
1225
+ { flag: '--status <active|paused>', description: 'set status' },
1226
+ { flag: '--counterparty <name>', description: 'set counterparty' },
1227
+ { flag: '--clear-counterparty', description: 'clear counterparty' },
1228
+ { flag: '--category <slug>', description: 'set category' },
1229
+ { flag: '--clear-category', description: 'clear category' },
1230
+ { flag: '--notes <text>', description: 'set notes' },
1231
+ { flag: '--clear-notes', description: 'clear notes' },
1232
+ { flag: '--currency <code>', description: 'set currency' },
1233
+ { flag: '--pretty', description: 'human output' },
1234
+ ],
1235
+ examples: [
1236
+ 'badgie-crm finance forecast update <id> --amount 420 --next-date 2026-06-01 --pretty',
1237
+ ],
1238
+ handler: financeForecastUpdate,
1239
+ },
1240
+ {
1241
+ path: ['finance', 'forecast', 'pause'],
1242
+ summary: 'Pause a manual forecast item',
1243
+ tags: ['write', 'http'],
1244
+ args: [{ name: 'id', required: true, description: 'finance_forecast_items.id' }],
1245
+ options: [{ flag: '--pretty', description: 'human output' }],
1246
+ handler: financeForecastPause,
1247
+ },
1248
+ {
1249
+ path: ['finance', 'forecast', 'resume'],
1250
+ summary: 'Resume a paused manual forecast item',
1251
+ tags: ['write', 'http'],
1252
+ args: [{ name: 'id', required: true, description: 'finance_forecast_items.id' }],
1253
+ options: [{ flag: '--pretty', description: 'human output' }],
1254
+ handler: financeForecastResume,
1255
+ },
1256
+ {
1257
+ path: ['finance', 'forecast', 'delete'],
1258
+ summary: 'Delete a manual forecast item',
1259
+ description: 'Destructive: deletes a row from finance_forecast_items via the Finance OS API. Agents must ask for explicit confirmation.',
1260
+ tags: ['destructive', 'http'],
1261
+ args: [{ name: 'id', required: true, description: 'finance_forecast_items.id' }],
1262
+ options: [{ flag: '--pretty', description: 'human output' }],
1263
+ handler: financeForecastDelete,
1264
+ },
1265
+ {
1266
+ path: ['finance', 'closings', 'list'],
1267
+ summary: 'List Finance OS monthly closings',
1268
+ tags: ['read', 'http'],
1269
+ options: [
1270
+ { flag: '--year <n>', description: 'filter by year' },
1271
+ { flag: '--month <n>', description: 'filter by month 1-12' },
1272
+ ...commonListOptions(),
1273
+ ],
1274
+ examples: ['badgie-crm finance closings list --year 2026 --pretty'],
1275
+ handler: financeClosingsList,
1276
+ },
1277
+ {
1278
+ path: ['finance', 'closings', 'close'],
1279
+ summary: 'Close a Finance OS month',
1280
+ description: 'Creates/updates finance_period_closings with an operational snapshot. Calls the app endpoint so audit log is written.',
1281
+ tags: ['write', 'http'],
1282
+ options: [
1283
+ { flag: '--year <n>', description: 'required year' },
1284
+ { flag: '--month <n>', description: 'required month 1-12' },
1285
+ { flag: '--notes <text>', description: 'closing notes' },
1286
+ { flag: '--pretty', description: 'human output' },
1287
+ ],
1288
+ examples: ['badgie-crm finance closings close --year 2026 --month 4 --notes "Abril revisado" --pretty'],
1289
+ handler: financeClosingClose,
1290
+ },
1291
+ {
1292
+ path: ['finance', 'closings', 'reopen'],
1293
+ summary: 'Reopen a Finance OS month',
1294
+ description: 'Reopens a closed operational period. Agents should confirm because this allows period mutations again.',
1295
+ tags: ['write', 'http'],
1296
+ options: [
1297
+ { flag: '--year <n>', description: 'required year' },
1298
+ { flag: '--month <n>', description: 'required month 1-12' },
1299
+ { flag: '--notes <text>', description: 'reason/notes' },
1300
+ { flag: '--pretty', description: 'human output' },
1301
+ ],
1302
+ examples: ['badgie-crm finance closings reopen --year 2026 --month 4 --notes "Corrección pendiente" --pretty'],
1303
+ handler: financeClosingReopen,
1304
+ },
571
1305
  {
572
1306
  path: ['finance', 'suppliers', 'list'],
573
1307
  summary: 'List suppliers',