estela 0.3.4 → 0.4.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 (39) hide show
  1. package/README.md +97 -0
  2. package/dist/billing/assign.d.ts +11 -1
  3. package/dist/billing/assign.js +59 -1
  4. package/dist/billing/sessionize.d.ts +6 -0
  5. package/dist/billing/sessionize.js +24 -5
  6. package/dist/cli.js +1084 -8
  7. package/dist/db/schema.js +91 -0
  8. package/dist/db/store.d.ts +28 -0
  9. package/dist/db/store.js +120 -1
  10. package/dist/delivery/clients.d.ts +52 -0
  11. package/dist/delivery/clients.js +94 -0
  12. package/dist/delivery/github.d.ts +21 -0
  13. package/dist/delivery/github.js +123 -0
  14. package/dist/delivery/report.d.ts +55 -0
  15. package/dist/delivery/report.js +124 -0
  16. package/dist/delivery/source.d.ts +68 -0
  17. package/dist/delivery/source.js +3 -0
  18. package/dist/export/csv.d.ts +14 -0
  19. package/dist/export/csv.js +78 -0
  20. package/dist/export/finance.d.ts +79 -0
  21. package/dist/export/finance.js +208 -0
  22. package/dist/i18n/en.js +166 -0
  23. package/dist/metrics/spend.d.ts +83 -0
  24. package/dist/metrics/spend.js +167 -0
  25. package/dist/metrics/undelivered.d.ts +104 -0
  26. package/dist/metrics/undelivered.js +199 -0
  27. package/dist/server.js +1 -0
  28. package/dist/watchers/git.d.ts +40 -0
  29. package/dist/watchers/git.js +87 -0
  30. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  32. package/node_modules/@estela/shared/dist/money.js +7 -7
  33. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  34. package/node_modules/@estela/shared/dist/types.d.ts +2 -0
  35. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  36. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  37. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deliveryReport = deliveryReport;
4
+ const shared_1 = require("@estela/shared");
5
+ const amortize_js_1 = require("../billing/amortize.js");
6
+ const spend_js_1 = require("../metrics/spend.js");
7
+ function deliveryReport(input) {
8
+ const inPeriod = (d) => {
9
+ if (!d)
10
+ return false;
11
+ const day = (0, shared_1.localDate)(d);
12
+ return day >= input.from && day <= input.to;
13
+ };
14
+ const isPlanned = (i) => !!input.plan && ((input.plan.label !== undefined && i.labels.includes(input.plan.label)) ||
15
+ (input.plan.milestone !== undefined && i.milestone === input.plan.milestone));
16
+ const prsDelivered = input.items.filter((i) => i.kind === "pr" && i.completed && inPeriod(i.closedAt));
17
+ const closersOf = (issue) => prsDelivered.filter((pr) => pr.closes.includes(issue.key));
18
+ const deliveredInPeriod = (i) => i.kind === "pr"
19
+ ? i.completed && inPeriod(i.closedAt)
20
+ : (i.completed && inPeriod(i.closedAt)) || closersOf(i).length > 0;
21
+ // Lo que se cerró antes del periodo ya no es plan de este periodo.
22
+ const planned = input.items.filter((i) => isPlanned(i) && !(i.closedAt && (0, shared_1.localDate)(i.closedAt) < input.from
23
+ && !deliveredInPeriod(i)));
24
+ const plannedKeys = new Set(planned.map((i) => i.key));
25
+ const unplanned = input.items.filter((i) => {
26
+ if (isPlanned(i) || !deliveredInPeriod(i))
27
+ return false;
28
+ // Un PR que entrega algo del plan es la entrega de ese plan, no un extra.
29
+ if (i.kind === "pr" && i.closes.some((k) => plannedKeys.has(k)))
30
+ return false;
31
+ // Una issue cerrada por un PR extra ya va con su PR.
32
+ if (i.kind === "issue" && closersOf(i).length > 0)
33
+ return false;
34
+ return true;
35
+ });
36
+ const costs = new EntryIndex(input);
37
+ const line = (item) => {
38
+ const deliveredBy = item.kind === "issue" ? closersOf(item) : [];
39
+ const { ids, basis } = costs.entriesOf(item, deliveredBy);
40
+ return { item, deliveredBy, basis, ...costs.cost(ids) };
41
+ };
42
+ const block = (items) => {
43
+ const lines = items.map(line).sort((a, b) => b.consumption.microUsd - a.consumption.microUsd
44
+ || a.item.number - b.item.number);
45
+ const ids = new Set();
46
+ for (const i of items)
47
+ for (const id of costs.entriesOf(i, i.kind === "issue" ? closersOf(i) : []).ids)
48
+ ids.add(id);
49
+ return { lines, ...costs.cost(ids) };
50
+ };
51
+ return {
52
+ planned: input.plan ? {
53
+ delivered: block(planned.filter(deliveredInPeriod)),
54
+ pending: block(planned.filter((i) => !deliveredInPeriod(i))),
55
+ } : null,
56
+ unplanned: block(unplanned),
57
+ };
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ /** Qué imputaciones se unen a cada issue o PR, y lo que cuestan. */
61
+ class EntryIndex {
62
+ input;
63
+ byId = new Map();
64
+ byBranch = new Map();
65
+ byItem = new Map();
66
+ assignedByMonth = new Map();
67
+ constructor(input) {
68
+ this.input = input;
69
+ const push = (map, k, id) => {
70
+ const list = map.get(k) ?? [];
71
+ if (!list.includes(id))
72
+ list.push(id);
73
+ map.set(k, list);
74
+ };
75
+ // El PR de un merge registrado en git también une su rama con su número.
76
+ const prOfBranch = new Map();
77
+ for (const m of input.merges)
78
+ if (m.sourceBranch && m.ref)
79
+ prOfBranch.set(m.sourceBranch, m.ref);
80
+ for (const e of input.entries) {
81
+ this.byId.set(e.id, e);
82
+ if (e.branch)
83
+ push(this.byBranch, e.branch, e.id);
84
+ for (const k of e.workItems ?? [])
85
+ push(this.byItem, k, e.id);
86
+ const pr = e.branch ? prOfBranch.get(e.branch) : undefined;
87
+ if (pr)
88
+ push(this.byItem, pr, e.id);
89
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
90
+ this.assignedByMonth.set(month, (this.assignedByMonth.get(month) ?? 0) + e.aiCost.microUsd);
91
+ }
92
+ }
93
+ entriesOf(item, deliveredBy) {
94
+ const ids = new Set();
95
+ const basis = new Set();
96
+ for (const i of [item, ...deliveredBy]) {
97
+ const fromBranch = i.kind === "pr" && i.branch ? this.byBranch.get(i.branch) ?? [] : [];
98
+ const fromItem = this.byItem.get(i.key) ?? [];
99
+ if (fromBranch.length)
100
+ basis.add("branch");
101
+ if (fromItem.length)
102
+ basis.add("item");
103
+ for (const id of [...fromBranch, ...fromItem])
104
+ ids.add(id);
105
+ }
106
+ // Siempre en el mismo orden: la rama primero.
107
+ return { ids, basis: ["branch", "item"].filter((b) => basis.has(b)) };
108
+ }
109
+ cost(ids) {
110
+ let seconds = 0;
111
+ let micro = 0;
112
+ const byMonth = new Map();
113
+ for (const id of ids) {
114
+ const e = this.byId.get(id);
115
+ seconds += e.seconds;
116
+ micro += e.aiCost.microUsd;
117
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
118
+ byMonth.set(month, (byMonth.get(month) ?? 0) + e.aiCost.microUsd);
119
+ }
120
+ const { real, partial } = (0, spend_js_1.realFromMonths)(byMonth, this.input.subscriptions, (month) => Math.max(this.input.monthTotals.get(month)?.microUsd ?? 0, this.assignedByMonth.get(month) ?? 0));
121
+ return { seconds, consumption: { microUsd: micro }, real, realPartial: partial };
122
+ }
123
+ }
124
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,68 @@
1
+ import type { TrackerSystem } from "@estela/shared";
2
+ /**
3
+ * De dónde sale lo planificado y lo entregado de un periodo.
4
+ *
5
+ * Una interfaz y no GitHub a pelo: el plan de un equipo vive en GitHub, en
6
+ * Linear o en Jira, y lo que Estela hace con él (compararlo con lo entregado y
7
+ * ponerle el coste de IA) es lo mismo en los tres. Hoy solo existe GitHub.
8
+ *
9
+ * Una fuente es la única parte de Estela que habla con un servicio externo, y
10
+ * solo cuando quien la usa ejecuta el comando en un proyecto que la tiene
11
+ * configurada. Lo que lee se guarda en la base (tracker_items) para poder
12
+ * repetir el informe sin conexión.
13
+ */
14
+ /** Una issue o un PR, ya normalizado. */
15
+ export interface TrackerItem {
16
+ /** Clave como las de los tickets de las imputaciones: `github:12`. */
17
+ readonly key: string;
18
+ readonly kind: "issue" | "pr";
19
+ readonly number: number;
20
+ readonly title: string;
21
+ readonly state: "open" | "closed" | "merged";
22
+ /** Cerrada como hecha (issue) o fusionado (PR). Cerrar como "no se hará" no es entregar. */
23
+ readonly completed: boolean;
24
+ /** Cuándo se cerró o se fusionó. */
25
+ readonly closedAt: Date | null;
26
+ /** Rama del PR. Es lo que une un squash con el trabajo de su rama. */
27
+ readonly branch: string | null;
28
+ /** Issues que cierra este PR (`github:12`). */
29
+ readonly closes: readonly string[];
30
+ readonly labels: readonly string[];
31
+ readonly milestone: string | null;
32
+ readonly url: string;
33
+ }
34
+ /** Qué cuenta como planificado: una etiqueta o un milestone. */
35
+ export interface PlanFilter {
36
+ readonly label?: string;
37
+ readonly milestone?: string;
38
+ }
39
+ export interface FetchQuery {
40
+ /** Fechas locales, inclusive. */
41
+ readonly from: string;
42
+ readonly to: string;
43
+ readonly plan: PlanFilter | null;
44
+ /**
45
+ * Prefijo de las etiquetas de cliente (`cliente:`). Con él se traen también
46
+ * las issues y PRs que las llevan, en cualquier estado: lo pedido por un
47
+ * cliente cuesta mientras se trabaja, no solo al entregarse.
48
+ */
49
+ readonly clientLabelPrefix?: string;
50
+ }
51
+ export type Availability = {
52
+ readonly ok: true;
53
+ } | {
54
+ readonly ok: false;
55
+ readonly reason: "missing" | "auth" | "repo";
56
+ readonly detail: string;
57
+ };
58
+ export interface PlanSource {
59
+ readonly system: TrackerSystem;
60
+ /** Si se puede leer ahora: instalado, con sesión y con un repo que la fuente entienda. */
61
+ availability(): Promise<Availability>;
62
+ /**
63
+ * Lo planificado (según el filtro, en cualquier estado) más lo entregado en
64
+ * el periodo (PRs fusionados e issues cerradas).
65
+ */
66
+ fetch(query: FetchQuery): Promise<TrackerItem[]>;
67
+ }
68
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=source.js.map
@@ -1,9 +1,23 @@
1
1
  import type { Client, Invoice, Project, TimeEntry } from "@estela/shared";
2
2
  import { formatMoney } from "@estela/shared";
3
+ import type { SpendBy, SpendProject, SpendProjectReport } from "../metrics/spend.js";
4
+ import { type UndeliveredReport } from "../metrics/undelivered.js";
3
5
  export declare function timeEntriesToCsv(entries: readonly TimeEntry[], project: Project, client: Client, hourlyRateOf: (at: Date) => {
4
6
  amount: number;
5
7
  currency: string;
6
8
  } | null): string;
7
9
  export declare function invoiceToCsv(invoice: Invoice, client: Client, project: Project): string;
10
+ /**
11
+ * `estela ai-cost --by … --csv`: una fila por rama o ticket. "Sin atribuir"
12
+ * va con la unidad vacía y atribuido = no, para que una tabla dinámica no la
13
+ * confunda con una rama que se llame así.
14
+ */
15
+ export declare function spendToCsv(reports: readonly SpendProjectReport[], by: SpendBy): string;
16
+ /**
17
+ * `estela ai-cost --undelivered --csv`: una fila por bloque y por rama. La
18
+ * columna "cuenta" dice si suma al gasto sin entrega: las ramas abiertas o de
19
+ * estado desconocido se listan, pero no cuentan.
20
+ */
21
+ export declare function undeliveredToCsv(reports: readonly UndeliveredReport[], projects: ReadonlyMap<string, SpendProject>): string;
8
22
  export { formatMoney };
9
23
  //# sourceMappingURL=csv.d.ts.map
@@ -3,10 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatMoney = void 0;
4
4
  exports.timeEntriesToCsv = timeEntriesToCsv;
5
5
  exports.invoiceToCsv = invoiceToCsv;
6
+ exports.spendToCsv = spendToCsv;
7
+ exports.undeliveredToCsv = undeliveredToCsv;
6
8
  const shared_1 = require("@estela/shared");
7
9
  Object.defineProperty(exports, "formatMoney", { enumerable: true, get: function () { return shared_1.formatMoney; } });
8
10
  const localize_js_1 = require("../billing/localize.js");
9
11
  const index_js_1 = require("../i18n/index.js");
12
+ const undelivered_js_1 = require("../metrics/undelivered.js");
10
13
  /**
11
14
  * Export a CSV.
12
15
  *
@@ -33,6 +36,14 @@ const COLUMNS = {
33
36
  lineHeader: ["concepto", "horas", "tarifa", "importe"],
34
37
  subtotal: "subtotal", aiBilled: "coste_ia_repercutido", total: "total",
35
38
  aiUsage: "consumo_ia_usd_equiv_api",
39
+ spend: ["proyecto", "cliente", "unidad", "atribuido", "ramas", "tickets", "pr", "segun", "horas",
40
+ "coste_ia_usd_equiv_api", "gasto_real", "moneda", "gasto_real_parcial", "incluye_copilot_sin_coste"],
41
+ undelivered: ["proyecto", "cliente", "bloque", "detalle", "cuenta", "dias_sin_actividad",
42
+ "coste_ia_usd_equiv_api", "gasto_real", "moneda", "gasto_real_parcial", "pct_del_gasto"],
43
+ blocks: { total: "gasto_del_periodo", sessions: "sesiones_sin_commit", later: "de_ellas_con_commit_despues",
44
+ unverifiable: "sesiones_sin_datos_de_commits",
45
+ unmerged: "rama_sin_fusionar", open: "rama_abierta", unknown: "rama_desconocida",
46
+ undelivered: "sin_entrega_total" },
36
47
  },
37
48
  en: {
38
49
  entries: ["date", "start", "end", "hours", "description", "project", "client",
@@ -44,6 +55,14 @@ const COLUMNS = {
44
55
  lineHeader: ["item", "hours", "rate", "amount"],
45
56
  subtotal: "subtotal", aiBilled: "ai_cost_passed_on", total: "total",
46
57
  aiUsage: "ai_usage_usd_api_equiv",
58
+ spend: ["project", "client", "unit", "attributed", "branches", "tickets", "pr", "basis", "hours",
59
+ "ai_cost_usd_api_equiv", "real_spend", "currency", "real_spend_partial", "includes_unpriced_copilot"],
60
+ undelivered: ["project", "client", "block", "detail", "counts", "idle_days",
61
+ "ai_cost_usd_api_equiv", "real_spend", "currency", "real_spend_partial", "pct_of_spend"],
62
+ blocks: { total: "period_spend", sessions: "sessions_without_commit", later: "of_which_committed_later",
63
+ unverifiable: "sessions_without_commit_data",
64
+ unmerged: "unmerged_branch", open: "open_branch", unknown: "unknown_branch",
65
+ undelivered: "undelivered_total" },
47
66
  },
48
67
  };
49
68
  /** Escapa un campo. Sin esto, un commit con una coma parte la fila en dos. */
@@ -113,4 +132,63 @@ function invoiceToCsv(invoice, client, project) {
113
132
  lines.push([c.aiUsage, "", "", (invoice.aiCost.microUsd / 1_000_000).toFixed(4)]);
114
133
  return rows(lines);
115
134
  }
135
+ /**
136
+ * `estela ai-cost --by … --csv`: una fila por rama o ticket. "Sin atribuir"
137
+ * va con la unidad vacía y atribuido = no, para que una tabla dinámica no la
138
+ * confunda con una rama que se llame así.
139
+ */
140
+ function spendToCsv(reports, by) {
141
+ const c = COLUMNS[(0, index_js_1.getLang)()];
142
+ const lines = [[...c.spend]];
143
+ for (const r of reports) {
144
+ for (const u of r.units) {
145
+ lines.push([
146
+ r.project.name,
147
+ r.project.clientName,
148
+ u.key === null ? "" : by === "branch" ? u.key : u.items.map(shared_1.formatWorkItem).join(" "),
149
+ u.key === null ? c.no : c.yes,
150
+ u.branches.join(" "),
151
+ u.items.map(shared_1.formatWorkItem).join(" "),
152
+ u.prs.map(shared_1.formatWorkItem).join(" "),
153
+ u.basis.join(" "),
154
+ (u.seconds / 3600).toFixed(4),
155
+ (u.consumption.microUsd / 1_000_000).toFixed(4),
156
+ u.real ? (u.real.amount / 10 ** shared_1.MINOR_UNIT_DIGITS[u.real.currency]).toFixed(shared_1.MINOR_UNIT_DIGITS[u.real.currency]) : "",
157
+ u.real?.currency ?? "",
158
+ u.realPartial ? c.yes : c.no,
159
+ u.unpricedAgents ? c.yes : c.no,
160
+ ]);
161
+ }
162
+ }
163
+ return rows(lines);
164
+ }
165
+ /**
166
+ * `estela ai-cost --undelivered --csv`: una fila por bloque y por rama. La
167
+ * columna "cuenta" dice si suma al gasto sin entrega: las ramas abiertas o de
168
+ * estado desconocido se listan, pero no cuentan.
169
+ */
170
+ function undeliveredToCsv(reports, projects) {
171
+ const c = COLUMNS[(0, index_js_1.getLang)()];
172
+ const lines = [[...c.undelivered]];
173
+ const amount = (s) => s.real
174
+ ? (s.real.amount / 10 ** shared_1.MINOR_UNIT_DIGITS[s.real.currency]).toFixed(shared_1.MINOR_UNIT_DIGITS[s.real.currency]) : "";
175
+ for (const r of reports) {
176
+ const p = projects.get(r.projectId);
177
+ const push = (block, detail, counts, idle, s) => lines.push([
178
+ p?.name ?? r.projectId, p?.clientName ?? "", block, detail, counts ? c.yes : c.no, idle ?? "",
179
+ (s.consumption.microUsd / 1_000_000).toFixed(4), amount(s), s.real?.currency ?? "",
180
+ s.realPartial ? c.yes : c.no, (0, undelivered_js_1.shareOf)(s, r.total)?.toFixed(2) ?? "",
181
+ ]);
182
+ push(c.blocks.total, "", false, null, r.total);
183
+ push(c.blocks.sessions, String(r.sessionsWithoutCommit.count), true, null, r.sessionsWithoutCommit);
184
+ push(c.blocks.later, String(r.sessionsWithoutCommit.laterCommitted.count), false, null, r.sessionsWithoutCommit.laterCommitted);
185
+ push(c.blocks.unverifiable, String(r.unverifiable.count), false, null, r.unverifiable);
186
+ for (const b of r.unmerged.branches)
187
+ push(c.blocks.unmerged, b.branch, true, b.idleDays, b);
188
+ for (const b of r.notCounted)
189
+ push(b.state === "open" ? c.blocks.open : c.blocks.unknown, b.branch, false, b.idleDays, b);
190
+ push(c.blocks.undelivered, "", false, null, r.undelivered);
191
+ }
192
+ return rows(lines);
193
+ }
116
194
  //# sourceMappingURL=csv.js.map
@@ -0,0 +1,79 @@
1
+ import type { Currency } from "@estela/shared";
2
+ import type { Cost } from "../delivery/report.js";
3
+ /**
4
+ * El informe para finanzas: el gasto de IA de un periodo contado para alguien
5
+ * que no programa.
6
+ *
7
+ * Los titulares hablan de dinero pagado, clientes y entregas; "tokens",
8
+ * "sesiones" y "precio de API" solo aparecen al final, en cómo se calcula.
9
+ * Junta lo que ya calculan los demás informes (ai-cost --by, --undelivered,
10
+ * delivery) sin calcular nada nuevo: aquí solo se ordena y se escribe.
11
+ */
12
+ export interface FinanceProject {
13
+ readonly id: string;
14
+ readonly name: string;
15
+ readonly cost: Cost;
16
+ }
17
+ export interface FinanceClient {
18
+ readonly id: string;
19
+ readonly name: string;
20
+ readonly cost: Cost;
21
+ readonly projects: readonly FinanceProject[];
22
+ }
23
+ export interface FinanceDelivery {
24
+ readonly label: string;
25
+ readonly cost: Cost;
26
+ }
27
+ export interface FinanceDeliveries {
28
+ readonly projectName: string;
29
+ readonly clientName: string;
30
+ /** "github": lo leído de GitHub con `estela delivery`; "git": solo merges y squash. */
31
+ readonly source: "github" | "git";
32
+ readonly fetchedAt: Date | null;
33
+ readonly delivered: readonly FinanceDelivery[];
34
+ readonly plan: {
35
+ readonly label: string;
36
+ readonly delivered: number;
37
+ readonly total: number;
38
+ readonly pending: Cost;
39
+ readonly unplanned: Cost;
40
+ } | null;
41
+ readonly requests: readonly {
42
+ readonly name: string;
43
+ readonly mapped: boolean;
44
+ readonly cost: Cost;
45
+ }[] | null;
46
+ readonly requestsRest: Cost | null;
47
+ }
48
+ export interface FinanceUndelivered {
49
+ readonly projectName: string;
50
+ readonly total: Cost;
51
+ readonly undelivered: Cost;
52
+ readonly withoutCommit: Cost;
53
+ readonly unmergedBranches: Cost;
54
+ readonly unmergedCount: number;
55
+ }
56
+ export interface FinanceSummary {
57
+ readonly from: string;
58
+ readonly to: string;
59
+ /** La moneda de la cuota; `null` sin suscripción registrada (todo va a precio de API). */
60
+ readonly currency: Currency | null;
61
+ /** Todo el gasto de la máquina en el periodo, con o sin proyecto. */
62
+ readonly paid: Cost;
63
+ readonly unassigned: Cost;
64
+ readonly clients: readonly FinanceClient[];
65
+ readonly deliveries: readonly FinanceDeliveries[];
66
+ readonly undelivered: readonly FinanceUndelivered[];
67
+ readonly staleDays: number;
68
+ /** Hubo trabajo con Copilot, cuyo coste no se conoce. */
69
+ readonly unpricedAgents: boolean;
70
+ }
71
+ /** "octubre de 2026" si el periodo es un mes entero; si no, las dos fechas. */
72
+ export declare function periodName(from: string, to: string): string;
73
+ export declare function financeToMarkdown(s: FinanceSummary): string;
74
+ /** Suma dos costes. El importe real se suma solo si los dos están en la misma moneda. */
75
+ export declare function sum(a: Cost, b: Cost): Cost;
76
+ /** Una fila por cifra, con la sección delante: se filtra en una hoja de cálculo. */
77
+ export declare function financeToCsv(s: FinanceSummary): string;
78
+ export declare function financeToJson(s: FinanceSummary): unknown;
79
+ //# sourceMappingURL=finance.d.ts.map
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.periodName = periodName;
4
+ exports.financeToMarkdown = financeToMarkdown;
5
+ exports.sum = sum;
6
+ exports.financeToCsv = financeToCsv;
7
+ exports.financeToJson = financeToJson;
8
+ const shared_1 = require("@estela/shared");
9
+ const index_js_1 = require("../i18n/index.js");
10
+ // ---------------------------------------------------------------------------
11
+ /** El importe que encabeza: lo pagado si hay cuota; si no, el valor a precio de API. */
12
+ function amount(c, s) {
13
+ if (!s.currency)
14
+ return (0, shared_1.formatAiCost)(c.consumption);
15
+ return (c.real ? (0, shared_1.formatMoney)(c.real) : (0, shared_1.formatMoney)({ amount: 0, currency: s.currency })) + (c.realPartial ? "†" : "");
16
+ }
17
+ function pct(part, total, s) {
18
+ const p = s.currency ? part.real?.amount ?? 0 : part.consumption.microUsd;
19
+ const t = s.currency ? total.real?.amount ?? 0 : total.consumption.microUsd;
20
+ return t === 0 ? "—" : `${((p / t) * 100).toFixed(1)} %`;
21
+ }
22
+ const hours = (c) => (c.seconds ? (0, shared_1.formatDuration)(c.seconds) : "—");
23
+ /** "octubre de 2026" si el periodo es un mes entero; si no, las dos fechas. */
24
+ function periodName(from, to) {
25
+ const month = from.slice(0, 7);
26
+ const last = new Date(Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0)).getUTCDate();
27
+ if (from === `${month}-01` && (to === `${month}-${last}` || to === `${month}-31`)) {
28
+ const locale = (0, index_js_1.getLang)() === "en" ? "en-GB" : "es-ES";
29
+ return new Date(`${month}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
30
+ }
31
+ return `${from} → ${to}`;
32
+ }
33
+ /** Una celda de tabla Markdown: sin barras ni saltos que la rompan. */
34
+ const md = (text) => text.replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
35
+ function financeToMarkdown(s) {
36
+ const out = [];
37
+ const projectsCount = s.clients.reduce((n, c) => n + c.projects.length, 0);
38
+ const undelivered = s.undelivered.reduce((acc, u) => sum(acc, u.undelivered), zero());
39
+ const projectsSpend = s.undelivered.reduce((acc, u) => sum(acc, u.total), zero());
40
+ const assigned = s.clients.reduce((acc, c) => sum(acc, c.cost), zero());
41
+ out.push((0, index_js_1.tr) `# Gasto en IA · ${periodName(s.from, s.to)}`, "");
42
+ out.push((0, index_js_1.tr) `## Resumen`, "");
43
+ out.push(s.currency
44
+ ? (0, index_js_1.tr) `- **Pagado en IA:** ${amount(s.paid, s)}, la cuota de las herramientas de IA repartida según su uso.`
45
+ : (0, index_js_1.tr) `- **Gasto en IA:** ${(0, shared_1.formatAiCost)(s.paid.consumption)} a precio de lista. No hay cuota registrada, así que es una estimación, no lo pagado.`);
46
+ const proyectos = projectsCount === 1 ? (0, index_js_1.tr) `1 proyecto` : (0, index_js_1.tr) `${projectsCount} proyectos`;
47
+ const clientes = s.clients.length === 1 ? (0, index_js_1.tr) `1 cliente` : (0, index_js_1.tr) `${s.clients.length} clientes`;
48
+ out.push((0, index_js_1.tr) `- **Horas de trabajo con IA:** ${hours(assigned)} en ${proyectos} de ${clientes}.`);
49
+ if (s.unassigned.consumption.microUsd > 0) {
50
+ out.push((0, index_js_1.tr) `- **Fuera de proyectos:** ${amount(s.unassigned, s)} (${pct(s.unassigned, s.paid, s)}) en trabajo que no es de ningún proyecto registrado.`);
51
+ }
52
+ if (projectsSpend.consumption.microUsd > 0) {
53
+ out.push((0, index_js_1.tr) `- **Sin entregar todavía:** ${pctApi(undelivered, projectsSpend)} del gasto en proyectos. Es una señal para revisar, no un juicio: explorar y descartar también es trabajo.`);
54
+ }
55
+ out.push("");
56
+ out.push((0, index_js_1.tr) `## Por cliente`, "");
57
+ out.push((0, index_js_1.tr) `| Cliente | Proyecto | Horas | Gasto | % del total |`, "|---|---|---:|---:|---:|");
58
+ for (const c of s.clients) {
59
+ for (const p of c.projects) {
60
+ out.push(`| ${md(c.name)} | ${md(p.name)} | ${hours(p.cost)} | ${amount(p.cost, s)} | ${pct(p.cost, s.paid, s)} |`);
61
+ }
62
+ if (c.projects.length > 1) {
63
+ out.push((0, index_js_1.tr) `| **${md(c.name)}** | **total** | **${hours(c.cost)}** | **${amount(c.cost, s)}** | **${pct(c.cost, s.paid, s)}** |`);
64
+ }
65
+ }
66
+ if (s.unassigned.consumption.microUsd > 0) {
67
+ out.push((0, index_js_1.tr) `| Fuera de proyectos | — | — | ${amount(s.unassigned, s)} | ${pct(s.unassigned, s.paid, s)} |`);
68
+ }
69
+ out.push((0, index_js_1.tr) `| **Total** | | **${hours(assigned)}** | **${amount(s.paid, s)}** | **100 %** |`, "");
70
+ const conEntregas = s.deliveries.filter((d) => d.delivered.length || d.plan);
71
+ if (conEntregas.length) {
72
+ out.push((0, index_js_1.tr) `## Qué se entregó`, "");
73
+ for (const d of conEntregas) {
74
+ out.push(`### ${md(d.projectName)} · ${md(d.clientName)}`, "");
75
+ if (d.plan) {
76
+ out.push((0, index_js_1.tr) `Del plan (${d.plan.label}) se entregaron ${d.plan.delivered} de ${d.plan.total}. Lo pendiente lleva ${amount(d.plan.pending, s)} de gasto; lo entregado fuera del plan, ${amount(d.plan.unplanned, s)}.`, "");
77
+ }
78
+ if (d.delivered.length) {
79
+ out.push((0, index_js_1.tr) `| Entrega | Horas | Gasto |`, "|---|---:|---:|");
80
+ for (const l of d.delivered.slice(0, 15))
81
+ out.push(`| ${md(l.label)} | ${hours(l.cost)} | ${amount(l.cost, s)} |`);
82
+ if (d.delivered.length > 15)
83
+ out.push((0, index_js_1.tr) `| … y ${d.delivered.length - 15} más | | |`);
84
+ out.push("");
85
+ }
86
+ out.push(d.source === "github"
87
+ ? (0, index_js_1.tr) `_Fuente: GitHub, leído el ${d.fetchedAt ? d.fetchedAt.toISOString().slice(0, 10) : "—"} con estela delivery._`
88
+ : (0, index_js_1.tr) `_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._`, "");
89
+ }
90
+ }
91
+ const conPeticiones = s.deliveries.filter((d) => d.requests?.length);
92
+ if (conPeticiones.length) {
93
+ out.push((0, index_js_1.tr) `## Trabajo pedido por clientes`, "");
94
+ out.push((0, index_js_1.tr) `Qué parte del gasto fue a peticiones concretas de un cliente. No incluye ingresos ni contratos: Estela no los conoce.`, "");
95
+ out.push((0, index_js_1.tr) `| Proyecto | Quién lo pidió | Horas | Gasto |`, "|---|---|---:|---:|");
96
+ for (const d of conPeticiones) {
97
+ for (const r of d.requests) {
98
+ out.push(`| ${md(d.projectName)} | ${md(r.mapped ? r.name : (0, index_js_1.tr) `${r.name} (sin identificar)`)} | ${hours(r.cost)} | ${amount(r.cost, s)} |`);
99
+ }
100
+ if (d.requestsRest)
101
+ out.push((0, index_js_1.tr) `| ${md(d.projectName)} | sin petición de cliente | ${hours(d.requestsRest)} | ${amount(d.requestsRest, s)} |`);
102
+ }
103
+ out.push("");
104
+ }
105
+ if (s.undelivered.length) {
106
+ out.push((0, index_js_1.tr) `## Gasto que no ha llegado a entregarse`, "");
107
+ out.push((0, index_js_1.tr) `Trabajo que todavía no está en la versión principal del producto. Es una señal para revisar, no un juicio: explorar, probar y descartar también es trabajo, y parte llegará más adelante.`, "");
108
+ out.push((0, index_js_1.tr) `| Proyecto | Sin entregar | % del proyecto | Trabajo sin consolidar | Líneas de trabajo paradas |`, "|---|---:|---:|---:|---:|");
109
+ for (const u of s.undelivered) {
110
+ out.push(`| ${md(u.projectName)} | ${amount(u.undelivered, s)} | ${pctApi(u.undelivered, u.total)} | ${amount(u.withoutCommit, s)} | ${amount(u.unmergedBranches, s)} (${u.unmergedCount}) |`);
111
+ }
112
+ out.push("");
113
+ }
114
+ out.push((0, index_js_1.tr) `## Cómo se calcula`, "");
115
+ out.push((0, index_js_1.tr) `- Las herramientas de IA (Claude Code, Codex, Copilot) dejan en el ordenador un registro de cada conversación. Estela lo lee junto con git, sin enviar nada fuera.`);
116
+ out.push(s.currency
117
+ ? (0, index_js_1.tr) `- **Gasto:** la cuota mensual de las suscripciones registradas, repartida según el uso (tokens a precio de lista) de cada proyecto ese mes. El precio de lista sirve solo para repartir: con cuota fija no es lo que se paga.`
118
+ : (0, index_js_1.tr) `- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.`);
119
+ out.push((0, index_js_1.tr) `- **Valor de uso a precio de lista:** ${(0, shared_1.formatAiCost)(s.paid.consumption)} en todo el periodo.`);
120
+ out.push((0, index_js_1.tr) `- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.`);
121
+ out.push((0, index_js_1.tr) `- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.`);
122
+ out.push((0, index_js_1.tr) `- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras ${s.staleDays} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.`);
123
+ if (s.unpricedAgents)
124
+ out.push((0, index_js_1.tr) `- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.`);
125
+ if (s.paid.realPartial)
126
+ out.push((0, index_js_1.tr) `- † Algún mes no tenía cuota registrada: el importe solo cuenta los meses con cuota.`);
127
+ return out.join("\n") + "\n";
128
+ }
129
+ const pctApi = (part, total) => total.consumption.microUsd === 0 ? "—"
130
+ : `${((part.consumption.microUsd / total.consumption.microUsd) * 100).toFixed(1)} %`;
131
+ function zero() { return { seconds: 0, consumption: { microUsd: 0 }, real: null, realPartial: false }; }
132
+ /** Suma dos costes. El importe real se suma solo si los dos están en la misma moneda. */
133
+ function sum(a, b) {
134
+ let real = a.real ?? b.real;
135
+ if (a.real && b.real && a.real.currency === b.real.currency)
136
+ real = { amount: a.real.amount + b.real.amount, currency: a.real.currency };
137
+ return { seconds: a.seconds + b.seconds, consumption: { microUsd: a.consumption.microUsd + b.consumption.microUsd },
138
+ real, realPartial: a.realPartial || b.realPartial };
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ const CSV = {
142
+ es: { header: ["seccion", "cliente", "proyecto", "concepto", "horas", "gasto", "moneda", "valor_uso_usd_precio_lista", "pct"],
143
+ summary: "resumen", client: "cliente", delivery: "entrega", request: "peticion", undelivered: "sin_entrega",
144
+ paid: "pagado", unassigned: "fuera_de_proyectos", project: "proyecto", rest: "sin_peticion", total: "sin_entregar" },
145
+ en: { header: ["section", "client", "project", "item", "hours", "spend", "currency", "usage_value_usd_list_price", "pct"],
146
+ summary: "summary", client: "client", delivery: "delivery", request: "request", undelivered: "undelivered",
147
+ paid: "paid", unassigned: "outside_projects", project: "project", rest: "no_request", total: "undelivered" },
148
+ };
149
+ /** Una fila por cifra, con la sección delante: se filtra en una hoja de cálculo. */
150
+ function financeToCsv(s) {
151
+ const c = CSV[(0, index_js_1.getLang)()];
152
+ const cell = (v) => {
153
+ const t = v === null || v === undefined ? "" : String(v);
154
+ return /[",\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
155
+ };
156
+ const money = (x) => {
157
+ if (!s.currency)
158
+ return ["", ""];
159
+ const m = x.real ?? { amount: 0, currency: s.currency };
160
+ const d = shared_1.MINOR_UNIT_DIGITS[m.currency];
161
+ return [(m.amount / 10 ** d).toFixed(d), m.currency];
162
+ };
163
+ const pctOf = (part, total) => {
164
+ const p = s.currency ? part.real?.amount ?? 0 : part.consumption.microUsd;
165
+ const t = s.currency ? total.real?.amount ?? 0 : total.consumption.microUsd;
166
+ return t === 0 ? "" : ((p / t) * 100).toFixed(2);
167
+ };
168
+ const rows = [[...c.header]];
169
+ const row = (section, client, project, item, x, p) => rows.push([section, client, project, item, (x.seconds / 3600).toFixed(4), ...money(x),
170
+ (x.consumption.microUsd / 1_000_000).toFixed(4), p]);
171
+ row(c.summary, "", "", c.paid, s.paid, "100.00");
172
+ row(c.summary, "", "", c.unassigned, s.unassigned, pctOf(s.unassigned, s.paid));
173
+ for (const cl of s.clients)
174
+ for (const p of cl.projects)
175
+ row(c.client, cl.name, p.name, c.project, p.cost, pctOf(p.cost, s.paid));
176
+ for (const d of s.deliveries) {
177
+ for (const l of d.delivered)
178
+ row(c.delivery, d.clientName, d.projectName, l.label, l.cost, "");
179
+ for (const r of d.requests ?? [])
180
+ row(c.request, d.clientName, d.projectName, r.name, r.cost, "");
181
+ if (d.requests?.length && d.requestsRest)
182
+ row(c.request, d.clientName, d.projectName, c.rest, d.requestsRest, "");
183
+ }
184
+ for (const u of s.undelivered)
185
+ row(c.undelivered, "", u.projectName, c.total, u.undelivered, pctApi(u.undelivered, u.total).replace(" %", ""));
186
+ // BOM para que Excel abra los acentos y el euro sin destrozarlos.
187
+ return "" + rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n";
188
+ }
189
+ function financeToJson(s) {
190
+ const cost = (x) => ({ seconds: x.seconds, apiMicroUsd: x.consumption.microUsd,
191
+ real: x.real ? { amountMinor: x.real.amount, currency: x.real.currency } : null, realPartial: x.realPartial });
192
+ return {
193
+ period: { from: s.from, to: s.to }, currency: s.currency, staleDays: s.staleDays, unpricedAgents: s.unpricedAgents,
194
+ paid: cost(s.paid), unassigned: cost(s.unassigned),
195
+ clients: s.clients.map((c) => ({ id: c.id, name: c.name, ...cost(c.cost),
196
+ projects: c.projects.map((p) => ({ id: p.id, name: p.name, ...cost(p.cost) })) })),
197
+ deliveries: s.deliveries.map((d) => ({
198
+ project: d.projectName, client: d.clientName, source: d.source, fetchedAt: d.fetchedAt?.toISOString() ?? null,
199
+ delivered: d.delivered.map((l) => ({ label: l.label, ...cost(l.cost) })),
200
+ plan: d.plan ? { ...d.plan, pending: cost(d.plan.pending), unplanned: cost(d.plan.unplanned) } : null,
201
+ requests: d.requests?.map((r) => ({ name: r.name, mapped: r.mapped, ...cost(r.cost) })) ?? null,
202
+ requestsRest: d.requestsRest ? cost(d.requestsRest) : null,
203
+ })),
204
+ undelivered: s.undelivered.map((u) => ({ project: u.projectName, total: cost(u.total), undelivered: cost(u.undelivered),
205
+ withoutCommit: cost(u.withoutCommit), unmergedBranches: cost(u.unmergedBranches), unmergedCount: u.unmergedCount })),
206
+ };
207
+ }
208
+ //# sourceMappingURL=finance.js.map