estela 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/dist/billing/amortize.d.ts +44 -0
- package/dist/billing/amortize.d.ts.map +1 -0
- package/dist/billing/amortize.js +83 -0
- package/dist/billing/amortize.js.map +1 -0
- package/dist/billing/amortize.test.d.ts +2 -0
- package/dist/billing/amortize.test.d.ts.map +1 -0
- package/dist/billing/amortize.test.js +121 -0
- package/dist/billing/amortize.test.js.map +1 -0
- package/dist/billing/format.test.d.ts +2 -0
- package/dist/billing/format.test.d.ts.map +1 -0
- package/dist/billing/format.test.js +110 -0
- package/dist/billing/format.test.js.map +1 -0
- package/dist/billing/invoice.d.ts +52 -0
- package/dist/billing/invoice.d.ts.map +1 -0
- package/dist/billing/invoice.js +148 -0
- package/dist/billing/invoice.js.map +1 -0
- package/dist/billing/invoice.test.d.ts +2 -0
- package/dist/billing/invoice.test.d.ts.map +1 -0
- package/dist/billing/invoice.test.js +200 -0
- package/dist/billing/invoice.test.js.map +1 -0
- package/dist/billing/sessionize.d.ts +104 -0
- package/dist/billing/sessionize.d.ts.map +1 -0
- package/dist/billing/sessionize.js +0 -0
- package/dist/billing/sessionize.js.map +1 -0
- package/dist/billing/sessionize.test.d.ts +2 -0
- package/dist/billing/sessionize.test.d.ts.map +1 -0
- package/dist/billing/sessionize.test.js +67 -0
- package/dist/billing/sessionize.test.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1091 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud/auth.d.ts +4 -0
- package/dist/cloud/auth.d.ts.map +1 -0
- package/dist/cloud/auth.js +98 -0
- package/dist/cloud/auth.js.map +1 -0
- package/dist/cloud/client.d.ts +12 -0
- package/dist/cloud/client.d.ts.map +1 -0
- package/dist/cloud/client.js +34 -0
- package/dist/cloud/client.js.map +1 -0
- package/dist/db/schema.d.ts +11 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +424 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/schema.test.d.ts +2 -0
- package/dist/db/schema.test.d.ts.map +1 -0
- package/dist/db/schema.test.js +335 -0
- package/dist/db/schema.test.js.map +1 -0
- package/dist/db/store.d.ts +113 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/db/store.js +550 -0
- package/dist/db/store.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +229 -0
- package/dist/doctor.js.map +1 -0
- package/dist/export/csv.d.ts +9 -0
- package/dist/export/csv.d.ts.map +1 -0
- package/dist/export/csv.js +81 -0
- package/dist/export/csv.js.map +1 -0
- package/dist/export/invoice-pdf.d.ts +21 -0
- package/dist/export/invoice-pdf.d.ts.map +1 -0
- package/dist/export/invoice-pdf.js +135 -0
- package/dist/export/invoice-pdf.js.map +1 -0
- package/dist/export/panel.d.ts +78 -0
- package/dist/export/panel.d.ts.map +1 -0
- package/dist/export/panel.js +693 -0
- package/dist/export/panel.js.map +1 -0
- package/dist/export/panel.test.d.ts +2 -0
- package/dist/export/panel.test.d.ts.map +1 -0
- package/dist/export/panel.test.js +273 -0
- package/dist/export/panel.test.js.map +1 -0
- package/dist/export/pdf.d.ts +32 -0
- package/dist/export/pdf.d.ts.map +1 -0
- package/dist/export/pdf.js +229 -0
- package/dist/export/pdf.js.map +1 -0
- package/dist/export/pdf.test.d.ts +2 -0
- package/dist/export/pdf.test.d.ts.map +1 -0
- package/dist/export/pdf.test.js +151 -0
- package/dist/export/pdf.test.js.map +1 -0
- package/dist/export/share.d.ts +42 -0
- package/dist/export/share.d.ts.map +1 -0
- package/dist/export/share.js +181 -0
- package/dist/export/share.js.map +1 -0
- package/dist/export/share.test.d.ts +2 -0
- package/dist/export/share.test.d.ts.map +1 -0
- package/dist/export/share.test.js +116 -0
- package/dist/export/share.test.js.map +1 -0
- package/dist/metrics/index.d.ts +101 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +221 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/metrics.test.d.ts +2 -0
- package/dist/metrics/metrics.test.d.ts.map +1 -0
- package/dist/metrics/metrics.test.js +162 -0
- package/dist/metrics/metrics.test.js.map +1 -0
- package/dist/metrics/people.d.ts +46 -0
- package/dist/metrics/people.d.ts.map +1 -0
- package/dist/metrics/people.js +126 -0
- package/dist/metrics/people.js.map +1 -0
- package/dist/metrics/people.test.d.ts +2 -0
- package/dist/metrics/people.test.d.ts.map +1 -0
- package/dist/metrics/people.test.js +84 -0
- package/dist/metrics/people.test.js.map +1 -0
- package/dist/metrics/team.d.ts +41 -0
- package/dist/metrics/team.d.ts.map +1 -0
- package/dist/metrics/team.js +0 -0
- package/dist/metrics/team.js.map +1 -0
- package/dist/metrics/team.test.d.ts +2 -0
- package/dist/metrics/team.test.d.ts.map +1 -0
- package/dist/metrics/team.test.js +90 -0
- package/dist/metrics/team.test.js.map +1 -0
- package/dist/pricing/catalog.d.ts +39 -0
- package/dist/pricing/catalog.d.ts.map +1 -0
- package/dist/pricing/catalog.js +59 -0
- package/dist/pricing/catalog.js.map +1 -0
- package/dist/pricing/cost.d.ts +25 -0
- package/dist/pricing/cost.d.ts.map +1 -0
- package/dist/pricing/cost.js +50 -0
- package/dist/pricing/cost.js.map +1 -0
- package/dist/pricing/cost.test.d.ts +2 -0
- package/dist/pricing/cost.test.d.ts.map +1 -0
- package/dist/pricing/cost.test.js +74 -0
- package/dist/pricing/cost.test.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1141 -0
- package/dist/server.js.map +1 -0
- package/dist/setup.d.ts +70 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +223 -0
- package/dist/setup.js.map +1 -0
- package/dist/setup.test.d.ts +2 -0
- package/dist/setup.test.d.ts.map +1 -0
- package/dist/setup.test.js +131 -0
- package/dist/setup.test.js.map +1 -0
- package/dist/watchers/claude.d.ts +34 -0
- package/dist/watchers/claude.d.ts.map +1 -0
- package/dist/watchers/claude.js +235 -0
- package/dist/watchers/claude.js.map +1 -0
- package/dist/watchers/claude.test.d.ts +2 -0
- package/dist/watchers/claude.test.d.ts.map +1 -0
- package/dist/watchers/claude.test.js +180 -0
- package/dist/watchers/claude.test.js.map +1 -0
- package/dist/watchers/git.d.ts +42 -0
- package/dist/watchers/git.d.ts.map +1 -0
- package/dist/watchers/git.js +218 -0
- package/dist/watchers/git.js.map +1 -0
- package/dist/watchers/git.test.d.ts +2 -0
- package/dist/watchers/git.test.d.ts.map +1 -0
- package/dist/watchers/git.test.js +73 -0
- package/dist/watchers/git.test.js.map +1 -0
- package/dist/watchers/identity.d.ts +34 -0
- package/dist/watchers/identity.d.ts.map +1 -0
- package/dist/watchers/identity.js +85 -0
- package/dist/watchers/identity.js.map +1 -0
- package/dist/watchers/identity.test.d.ts +2 -0
- package/dist/watchers/identity.test.d.ts.map +1 -0
- package/dist/watchers/identity.test.js +48 -0
- package/dist/watchers/identity.test.js.map +1 -0
- package/dist/web-ui.test.d.ts +2 -0
- package/dist/web-ui.test.d.ts.map +1 -0
- package/dist/web-ui.test.js +312 -0
- package/dist/web-ui.test.js.map +1 -0
- package/node_modules/@estela/shared/dist/index.d.ts +3 -0
- package/node_modules/@estela/shared/dist/index.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/index.js +19 -0
- package/node_modules/@estela/shared/dist/index.js.map +1 -0
- package/node_modules/@estela/shared/dist/money.d.ts +71 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/money.js +171 -0
- package/node_modules/@estela/shared/dist/money.js.map +1 -0
- package/node_modules/@estela/shared/dist/types.d.ts +365 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/types.js +24 -0
- package/node_modules/@estela/shared/dist/types.js.map +1 -0
- package/node_modules/@estela/shared/package.json +5 -0
- package/package.json +41 -0
- package/web/app.css +749 -0
- package/web/app.js +1257 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/favicon.png +0 -0
- package/web/index.html +132 -0
- package/web/lib.js +94 -0
- package/web/logo-mark.png +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Estela
|
|
2
|
+
|
|
3
|
+
Registro de horas de desarrollo que **no hay que rellenar**. Lee lo que tus
|
|
4
|
+
agentes de IA y tu Git ya escribieron en disco y reconstruye en qué se te fue el
|
|
5
|
+
tiempo, con lo que costó de IA.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx estela setup
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Veinte segundos después tienes tu historial de los últimos meses. Sin cuenta,
|
|
12
|
+
sin tarjeta y sin que nada salga de tu máquina.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Por qué existe
|
|
17
|
+
|
|
18
|
+
Un cronómetro que hay que acordarse de arrancar siempre falla. Y con un agente
|
|
19
|
+
de IA el tiempo ya no se mide tecleando: se va en escribir el prompt, leer lo
|
|
20
|
+
que devuelve y probarlo.
|
|
21
|
+
|
|
22
|
+
Pero ese trabajo **deja rastro**. Claude Code guarda cada sesión en
|
|
23
|
+
`~/.claude/projects/` con su modelo y sus tokens exactos. Git guarda cuándo
|
|
24
|
+
commiteaste y qué. Estela lee ambas cosas y las cruza.
|
|
25
|
+
|
|
26
|
+
Lo que no hace:
|
|
27
|
+
|
|
28
|
+
- **No instala hooks.** Tu `husky` y tu `lefthook` siguen intactos, y tus
|
|
29
|
+
mensajes de commit no se tocan. Un identificador metido en el historial es
|
|
30
|
+
irreversible una vez subido.
|
|
31
|
+
- **No inspecciona procesos ni la terminal.** Lee ficheros que ya existen.
|
|
32
|
+
- **No guarda el contenido de tus prompts.** Solo cuándo, cuánto y con qué
|
|
33
|
+
modelo.
|
|
34
|
+
- **No manda nada a ningún sitio.** El plan gratuito es local entero.
|
|
35
|
+
|
|
36
|
+
## Empezar
|
|
37
|
+
|
|
38
|
+
Necesitas **Node 22.5 o superior** (por `node:sqlite`).
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx estela setup # detecta agentes y repositorios, reconstruye tu historial
|
|
42
|
+
npx estela web # abre el panel en http://localhost:4319
|
|
43
|
+
npx estela doctor # revisa los datos y avisa de lo que está mal
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`setup` no pregunta nada y no pisa lo que hayas configurado a mano: se puede
|
|
47
|
+
volver a ejecutar.
|
|
48
|
+
|
|
49
|
+
## Facturar a un cliente
|
|
50
|
+
|
|
51
|
+
Los proyectos se crean como internos y sin tarifa, porque inventarla daría
|
|
52
|
+
cifras falsas el primer minuto. Cuando uno sea de un cliente de verdad:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
estela client add --id acme --name "ACME" --currency EUR
|
|
56
|
+
estela project add --id acme-web --client acme --name "Web de ACME" --repo ~/dev/acme-web
|
|
57
|
+
estela rate set --project acme-web --rate 50
|
|
58
|
+
|
|
59
|
+
estela author --project acme-web # con qué correo commiteas ahí
|
|
60
|
+
estela report --project acme-web --cutoff 2026-08-31 --dry-run
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**`estela author` importa más de lo que parece.** En el repositorio de un
|
|
64
|
+
cliente casi nunca commiteas con tu correo global, y sin decirlo se capturan
|
|
65
|
+
tres commits de mil setecientos.
|
|
66
|
+
|
|
67
|
+
## Compartir el avance con tu cliente
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
estela publish --project acme-web --base-url https://tu-dominio.com
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Genera un HTML autocontenido con un enlace no adivinable. Enseña horas y
|
|
74
|
+
commits; **nunca tu tarifa ni tu consumo de IA**, porque mientras la pagues tú
|
|
75
|
+
ese gasto es tuyo y un cliente que sabe qué parte generó una IA tiene un
|
|
76
|
+
argumento nuevo para negociar tu tarifa.
|
|
77
|
+
|
|
78
|
+
Al republicar, pasa el mismo `--token` o tu cliente se queda con un enlace
|
|
79
|
+
muerto.
|
|
80
|
+
|
|
81
|
+
## El coste de la IA
|
|
82
|
+
|
|
83
|
+
Con una cuota plana el gasto real no es la suma de los tokens: es la cuota
|
|
84
|
+
repartida entre lo que consumiste.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
estela subscription add --id max --name "Claude Max" --fee 100
|
|
88
|
+
estela ai-cost
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
La caché se cuenta aparte porque suele ser la mayor parte de la factura —
|
|
92
|
+
ignorarla subestimaba el gasto cinco veces.
|
|
93
|
+
|
|
94
|
+
## Horas que ningún import va a deducir
|
|
95
|
+
|
|
96
|
+
Reuniones, desplazamientos, investigación, y desarrollo sin agente que tampoco
|
|
97
|
+
dejó commits:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
estela log --project acme-web --hours 1.5 --kind meeting --what "Seguimiento semanal"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Un import nunca las toca.
|
|
104
|
+
|
|
105
|
+
## Trabajo sin agente
|
|
106
|
+
|
|
107
|
+
Si programaste a mano, tus commits siguen siendo un rastro: Estela deduce el
|
|
108
|
+
tiempo de ellos y lo marca como **estimado**, para que sepas qué parte de tu
|
|
109
|
+
parte está medida y cuál supuesta. La estimación se queda corta a propósito:
|
|
110
|
+
estas horas acaban en un informe que alguien paga.
|
|
111
|
+
|
|
112
|
+
## Planes
|
|
113
|
+
|
|
114
|
+
| | Free | Pro | Teams |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| Todo lo de arriba, en local | ✓ | ✓ | ✓ |
|
|
117
|
+
| Sincronizar varias máquinas | — | ✓ | ✓ |
|
|
118
|
+
| Paneles ilimitados y alojados | 1 | ✓ | ✓ |
|
|
119
|
+
| Horas del equipo **medidas** | — | — | ✓ |
|
|
120
|
+
| Presupuesto de IA por proyecto | — | — | ✓ |
|
|
121
|
+
|
|
122
|
+
Pro es una persona en varias máquinas; Teams son varias personas. El coste de
|
|
123
|
+
IA se informa **por proyecto, nunca por persona**: lo que cada cual gasta de su
|
|
124
|
+
bolsillo es suyo.
|
|
125
|
+
|
|
126
|
+
Más en [getestela.dev](https://getestela.dev).
|
|
127
|
+
|
|
128
|
+
## Desarrollo
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
npm install
|
|
132
|
+
npm run build
|
|
133
|
+
node --test packages/daemon/dist/**/*.test.js
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Node 22 y **cero dependencias de runtime**, a propósito: nadie instala un
|
|
137
|
+
programa que lee sus transcripts si no puede auditarlo, y una lista de
|
|
138
|
+
dependencias vacía se audita en una tarde.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AiCost, AmortizedShare, Money, Subscription } from "@estela/shared";
|
|
2
|
+
/**
|
|
3
|
+
* Reparto de una cuota fija entre proyectos.
|
|
4
|
+
*
|
|
5
|
+
* Con una suscripción de precio fijo, sumar el coste por token da un número que
|
|
6
|
+
* no salió de tu bolsillo. Lo que gastaste de verdad es la cuota. Para saber
|
|
7
|
+
* cuánto de esa cuota "se comió" un proyecto, se reparte en proporción a lo que
|
|
8
|
+
* consumió, usando la tarifa API equivalente como unidad de medida.
|
|
9
|
+
*
|
|
10
|
+
* parte_del_proyecto = cuota_mensual x (consumo_proyecto / consumo_total_mes)
|
|
11
|
+
*
|
|
12
|
+
* El denominador es **todo** tu consumo del mes, incluido el de proyectos no
|
|
13
|
+
* facturables. Si tus experimentos personales se comieron la mitad de la cuota,
|
|
14
|
+
* a los clientes solo les corresponde la otra mitad. Repartir únicamente entre
|
|
15
|
+
* lo facturable les cargaría un gasto que no generaron.
|
|
16
|
+
*/
|
|
17
|
+
export interface ConsumptionRow {
|
|
18
|
+
readonly projectId: string;
|
|
19
|
+
/** Mes en formato YYYY-MM. */
|
|
20
|
+
readonly month: string;
|
|
21
|
+
readonly consumption: AiCost;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @param monthTotals Consumo total del mes en **toda** tu máquina, incluido el
|
|
25
|
+
* trabajo que aún no has asignado a ningún proyecto. Es el denominador
|
|
26
|
+
* correcto: la cuota la pagaste entera, la usaras en lo que la usaras.
|
|
27
|
+
*
|
|
28
|
+
* Sin este dato, el denominador sería solo la suma de los proyectos
|
|
29
|
+
* configurados y el primero que registres cargaría con toda la cuota, aunque
|
|
30
|
+
* la mayor parte de tu consumo estuviera en repos sin configurar.
|
|
31
|
+
*/
|
|
32
|
+
export declare function amortize(rows: readonly ConsumptionRow[], subscriptions: readonly Subscription[], monthTotals?: ReadonlyMap<string, AiCost>): AmortizedShare[];
|
|
33
|
+
/**
|
|
34
|
+
* Suma de las cuotas vigentes en un mes.
|
|
35
|
+
*
|
|
36
|
+
* Se suman porque es habitual pagar varias a la vez (Claude Max y Cursor, por
|
|
37
|
+
* ejemplo) y todas alimentan el mismo trabajo.
|
|
38
|
+
*/
|
|
39
|
+
export declare function feeForMonth(subscriptions: readonly Subscription[], month: string): Money | null;
|
|
40
|
+
/** Mes (YYYY-MM, en UTC) de una fecha. */
|
|
41
|
+
export declare function monthOf(date: Date): string;
|
|
42
|
+
/** Lo que le corresponde a un proyecto en un rango de meses. */
|
|
43
|
+
export declare function shareForProject(shares: readonly AmortizedShare[], projectId: string, months: readonly string[]): Money | null;
|
|
44
|
+
//# sourceMappingURL=amortize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amortize.d.ts","sourceRoot":"","sources":["../../src/billing/amortize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAGlF;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8BAA8B;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CACtB,IAAI,EAAE,SAAS,cAAc,EAAE,EAC/B,aAAa,EAAE,SAAS,YAAY,EAAE,EACtC,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GACxC,cAAc,EAAE,CAiClB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,aAAa,EAAE,SAAS,YAAY,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,KAAK,GAAG,IAAI,CAkB/F;AAED,0CAA0C;AAC1C,wBAAgB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE1C;AAED,gEAAgE;AAChE,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,cAAc,EAAE,EACjC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,KAAK,GAAG,IAAI,CAMd"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.amortize = amortize;
|
|
4
|
+
exports.feeForMonth = feeForMonth;
|
|
5
|
+
exports.monthOf = monthOf;
|
|
6
|
+
exports.shareForProject = shareForProject;
|
|
7
|
+
const shared_1 = require("@estela/shared");
|
|
8
|
+
/**
|
|
9
|
+
* @param monthTotals Consumo total del mes en **toda** tu máquina, incluido el
|
|
10
|
+
* trabajo que aún no has asignado a ningún proyecto. Es el denominador
|
|
11
|
+
* correcto: la cuota la pagaste entera, la usaras en lo que la usaras.
|
|
12
|
+
*
|
|
13
|
+
* Sin este dato, el denominador sería solo la suma de los proyectos
|
|
14
|
+
* configurados y el primero que registres cargaría con toda la cuota, aunque
|
|
15
|
+
* la mayor parte de tu consumo estuviera en repos sin configurar.
|
|
16
|
+
*/
|
|
17
|
+
function amortize(rows, subscriptions, monthTotals) {
|
|
18
|
+
const byMonth = new Map();
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
const bucket = byMonth.get(row.month);
|
|
21
|
+
if (bucket)
|
|
22
|
+
bucket.push(row);
|
|
23
|
+
else
|
|
24
|
+
byMonth.set(row.month, [row]);
|
|
25
|
+
}
|
|
26
|
+
const shares = [];
|
|
27
|
+
for (const [month, monthRows] of byMonth) {
|
|
28
|
+
const fee = feeForMonth(subscriptions, month);
|
|
29
|
+
const assigned = monthRows.reduce((sum, r) => sum + r.consumption.microUsd, 0);
|
|
30
|
+
// Nunca por debajo de lo ya asignado: un total incoherente no puede hacer
|
|
31
|
+
// que las partes sumen más del 100%.
|
|
32
|
+
const total = Math.max(monthTotals?.get(month)?.microUsd ?? 0, assigned);
|
|
33
|
+
for (const row of monthRows) {
|
|
34
|
+
const share = total === 0 ? 0 : row.consumption.microUsd / total;
|
|
35
|
+
shares.push({
|
|
36
|
+
projectId: row.projectId,
|
|
37
|
+
month,
|
|
38
|
+
consumption: row.consumption,
|
|
39
|
+
monthTotal: { microUsd: total },
|
|
40
|
+
share,
|
|
41
|
+
amount: fee
|
|
42
|
+
? (0, shared_1.money)(Math.round(fee.amount * share), fee.currency)
|
|
43
|
+
: (0, shared_1.money)(0, "USD"),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return shares.sort((a, b) => a.month.localeCompare(b.month) || a.projectId.localeCompare(b.projectId));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Suma de las cuotas vigentes en un mes.
|
|
51
|
+
*
|
|
52
|
+
* Se suman porque es habitual pagar varias a la vez (Claude Max y Cursor, por
|
|
53
|
+
* ejemplo) y todas alimentan el mismo trabajo.
|
|
54
|
+
*/
|
|
55
|
+
function feeForMonth(subscriptions, month) {
|
|
56
|
+
const start = new Date(`${month}-01T00:00:00Z`);
|
|
57
|
+
const active = subscriptions.filter((s) => s.effectiveFrom <= start && (s.effectiveTo === null || start < s.effectiveTo));
|
|
58
|
+
if (active.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const currency = active[0].monthlyFee.currency;
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const s of active) {
|
|
63
|
+
if (s.monthlyFee.currency !== currency) {
|
|
64
|
+
throw new TypeError(`Suscripciones en monedas distintas en ${month} (${currency} y ${s.monthlyFee.currency}). ` +
|
|
65
|
+
`Únifica la moneda de las suscripciones: no se convierten automáticamente.`);
|
|
66
|
+
}
|
|
67
|
+
total += s.monthlyFee.amount;
|
|
68
|
+
}
|
|
69
|
+
return (0, shared_1.money)(total, currency);
|
|
70
|
+
}
|
|
71
|
+
/** Mes (YYYY-MM, en UTC) de una fecha. */
|
|
72
|
+
function monthOf(date) {
|
|
73
|
+
return date.toISOString().slice(0, 7);
|
|
74
|
+
}
|
|
75
|
+
/** Lo que le corresponde a un proyecto en un rango de meses. */
|
|
76
|
+
function shareForProject(shares, projectId, months) {
|
|
77
|
+
const relevant = shares.filter((s) => s.projectId === projectId && months.includes(s.month));
|
|
78
|
+
if (relevant.length === 0)
|
|
79
|
+
return null;
|
|
80
|
+
const currency = relevant[0].amount.currency;
|
|
81
|
+
return (0, shared_1.money)(relevant.reduce((sum, s) => sum + s.amount.amount, 0), currency);
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=amortize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amortize.js","sourceRoot":"","sources":["../../src/billing/amortize.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amortize.test.d.ts","sourceRoot":"","sources":["../../src/billing/amortize.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = require("node:test");
|
|
8
|
+
const shared_1 = require("@estela/shared");
|
|
9
|
+
const amortize_js_1 = require("./amortize.js");
|
|
10
|
+
const CLAUDE_MAX = {
|
|
11
|
+
id: "claude-max", name: "Claude Max",
|
|
12
|
+
monthlyFee: (0, shared_1.parseMoney)("200", "USD"),
|
|
13
|
+
effectiveFrom: new Date("2025-01-01T00:00:00Z"), effectiveTo: null,
|
|
14
|
+
};
|
|
15
|
+
(0, node_test_1.test)("reparte la cuota en proporción al consumo", () => {
|
|
16
|
+
const shares = (0, amortize_js_1.amortize)([
|
|
17
|
+
{ projectId: "a", month: "2026-08", consumption: { microUsd: 75_000_000 } }, // 75%
|
|
18
|
+
{ projectId: "b", month: "2026-08", consumption: { microUsd: 25_000_000 } }, // 25%
|
|
19
|
+
], [CLAUDE_MAX]);
|
|
20
|
+
const a = shares.find((s) => s.projectId === "a");
|
|
21
|
+
const b = shares.find((s) => s.projectId === "b");
|
|
22
|
+
strict_1.default.equal(a.share, 0.75);
|
|
23
|
+
strict_1.default.equal((0, shared_1.formatMoney)(a.amount), "$150,00");
|
|
24
|
+
strict_1.default.equal((0, shared_1.formatMoney)(b.amount), "$50,00");
|
|
25
|
+
strict_1.default.equal(a.amount.amount + b.amount.amount, 20_000, "el reparto suma la cuota entera");
|
|
26
|
+
});
|
|
27
|
+
(0, node_test_1.test)("lo no facturable también consume cuota, y por eso entra en el denominador", () => {
|
|
28
|
+
// Si tus experimentos se comen la mitad, al cliente solo le toca la otra mitad.
|
|
29
|
+
const shares = (0, amortize_js_1.amortize)([
|
|
30
|
+
{ projectId: "cliente", month: "2026-08", consumption: { microUsd: 50_000_000 } },
|
|
31
|
+
{ projectId: "experimentos", month: "2026-08", consumption: { microUsd: 50_000_000 } },
|
|
32
|
+
], [CLAUDE_MAX]);
|
|
33
|
+
const cliente = shares.find((s) => s.projectId === "cliente");
|
|
34
|
+
strict_1.default.equal((0, shared_1.formatMoney)(cliente.amount), "$100,00");
|
|
35
|
+
strict_1.default.equal(cliente.share, 0.5);
|
|
36
|
+
});
|
|
37
|
+
(0, node_test_1.test)("el trabajo sin asignar a proyecto también diluye la cuota", () => {
|
|
38
|
+
// El fallo que esto previene: con un solo proyecto configurado y el 90% del
|
|
39
|
+
// consumo en repos sin registrar, ese proyecto cargaba con la cuota entera.
|
|
40
|
+
const shares = (0, amortize_js_1.amortize)([{ projectId: "eurosistema", month: "2026-08", consumption: { microUsd: 10_000_000 } }], [CLAUDE_MAX], new Map([["2026-08", { microUsd: 100_000_000 }]])); // total real de la máquina
|
|
41
|
+
strict_1.default.equal(shares[0].share, 0.1);
|
|
42
|
+
strict_1.default.equal((0, shared_1.formatMoney)(shares[0].amount), "$20,00");
|
|
43
|
+
strict_1.default.equal(shares[0].monthTotal.microUsd, 100_000_000, "el denominador es el consumo real de la máquina, no la suma de proyectos");
|
|
44
|
+
});
|
|
45
|
+
(0, node_test_1.test)("un total incoherente nunca hace que las partes pasen del 100%", () => {
|
|
46
|
+
const shares = (0, amortize_js_1.amortize)([{ projectId: "a", month: "2026-08", consumption: { microUsd: 50_000_000 } }], [CLAUDE_MAX], new Map([["2026-08", { microUsd: 10_000_000 }]])); // menor que lo asignado
|
|
47
|
+
strict_1.default.equal(shares[0].share, 1, "se limita al 100%, no al 500%");
|
|
48
|
+
strict_1.default.equal((0, shared_1.formatMoney)(shares[0].amount), "$200,00");
|
|
49
|
+
});
|
|
50
|
+
(0, node_test_1.test)("el reparto es por mes, no acumulado", () => {
|
|
51
|
+
const shares = (0, amortize_js_1.amortize)([
|
|
52
|
+
{ projectId: "a", month: "2026-07", consumption: { microUsd: 10_000_000 } },
|
|
53
|
+
{ projectId: "a", month: "2026-08", consumption: { microUsd: 30_000_000 } },
|
|
54
|
+
{ projectId: "b", month: "2026-08", consumption: { microUsd: 10_000_000 } },
|
|
55
|
+
], [CLAUDE_MAX]);
|
|
56
|
+
const julio = shares.find((s) => s.projectId === "a" && s.month === "2026-07");
|
|
57
|
+
const agosto = shares.find((s) => s.projectId === "a" && s.month === "2026-08");
|
|
58
|
+
strict_1.default.equal((0, shared_1.formatMoney)(julio.amount), "$200,00", "único proyecto en julio: cuota entera");
|
|
59
|
+
strict_1.default.equal((0, shared_1.formatMoney)(agosto.amount), "$150,00", "75% de agosto");
|
|
60
|
+
});
|
|
61
|
+
(0, node_test_1.test)("varias suscripciones a la vez se suman", () => {
|
|
62
|
+
const cursor = {
|
|
63
|
+
id: "cursor", name: "Cursor", monthlyFee: (0, shared_1.parseMoney)("20", "USD"),
|
|
64
|
+
effectiveFrom: new Date("2025-01-01T00:00:00Z"), effectiveTo: null,
|
|
65
|
+
};
|
|
66
|
+
strict_1.default.equal((0, shared_1.formatMoney)((0, amortize_js_1.feeForMonth)([CLAUDE_MAX, cursor], "2026-08")), "$220,00");
|
|
67
|
+
});
|
|
68
|
+
(0, node_test_1.test)("una suscripción no vigente no cuenta", () => {
|
|
69
|
+
const antigua = {
|
|
70
|
+
...CLAUDE_MAX, id: "vieja",
|
|
71
|
+
effectiveTo: new Date("2026-01-01T00:00:00Z"),
|
|
72
|
+
};
|
|
73
|
+
strict_1.default.equal((0, amortize_js_1.feeForMonth)([antigua], "2026-08"), null);
|
|
74
|
+
strict_1.default.equal((0, shared_1.formatMoney)((0, amortize_js_1.feeForMonth)([antigua], "2025-06")), "$200,00");
|
|
75
|
+
});
|
|
76
|
+
(0, node_test_1.test)("suscripciones en monedas distintas se rechazan en vez de convertirse", () => {
|
|
77
|
+
const enEuros = {
|
|
78
|
+
id: "x", name: "X", monthlyFee: (0, shared_1.money)(2000, "EUR"),
|
|
79
|
+
effectiveFrom: new Date("2025-01-01T00:00:00Z"), effectiveTo: null,
|
|
80
|
+
};
|
|
81
|
+
strict_1.default.throws(() => (0, amortize_js_1.feeForMonth)([CLAUDE_MAX, enEuros], "2026-08"), /monedas distintas/);
|
|
82
|
+
});
|
|
83
|
+
(0, node_test_1.test)("sin suscripción, el reparto sale a cero en vez de inventar un coste", () => {
|
|
84
|
+
const shares = (0, amortize_js_1.amortize)([{ projectId: "a", month: "2026-08", consumption: { microUsd: 8_016_655 } }], []);
|
|
85
|
+
strict_1.default.equal(shares[0].amount.amount, 0);
|
|
86
|
+
strict_1.default.equal(shares[0].consumption.microUsd, 8_016_655, "el consumo sí se conserva");
|
|
87
|
+
});
|
|
88
|
+
(0, node_test_1.test)("consumo cero no divide por cero", () => {
|
|
89
|
+
const shares = (0, amortize_js_1.amortize)([{ projectId: "a", month: "2026-08", consumption: { microUsd: 0 } }], [CLAUDE_MAX]);
|
|
90
|
+
strict_1.default.equal(shares[0].share, 0);
|
|
91
|
+
strict_1.default.equal(shares[0].amount.amount, 0);
|
|
92
|
+
});
|
|
93
|
+
(0, node_test_1.test)("suma de un proyecto a lo largo de varios meses", () => {
|
|
94
|
+
const shares = (0, amortize_js_1.amortize)([
|
|
95
|
+
{ projectId: "a", month: "2026-07", consumption: { microUsd: 100_000_000 } },
|
|
96
|
+
{ projectId: "a", month: "2026-08", consumption: { microUsd: 100_000_000 } },
|
|
97
|
+
], [CLAUDE_MAX]);
|
|
98
|
+
const total = (0, amortize_js_1.shareForProject)(shares, "a", ["2026-07", "2026-08"]);
|
|
99
|
+
strict_1.default.equal((0, shared_1.formatMoney)(total), "$400,00");
|
|
100
|
+
const soloJulio = (0, amortize_js_1.shareForProject)(shares, "a", ["2026-07"]);
|
|
101
|
+
strict_1.default.equal((0, shared_1.formatMoney)(soloJulio), "$200,00");
|
|
102
|
+
});
|
|
103
|
+
(0, node_test_1.test)("monthOf usa UTC de forma consistente", () => {
|
|
104
|
+
strict_1.default.equal((0, amortize_js_1.monthOf)(new Date("2026-08-26T23:30:00Z")), "2026-08");
|
|
105
|
+
strict_1.default.equal((0, amortize_js_1.monthOf)(new Date("2026-09-01T00:00:00Z")), "2026-09");
|
|
106
|
+
});
|
|
107
|
+
(0, node_test_1.test)("el reparto de la cuota se congela en el informe", () => {
|
|
108
|
+
// El fallo que esto previene: el reparto es un valor derivado que cambia al
|
|
109
|
+
// entrar más consumo del mismo mes. Sin congelarlo, reimprimir un informe de
|
|
110
|
+
// agosto en diciembre daría otra cifra, y un documento que cambia solo no es
|
|
111
|
+
// un documento.
|
|
112
|
+
const enAgosto = (0, amortize_js_1.amortize)([{ projectId: "a", month: "2026-08", consumption: { microUsd: 50_000_000 } }], [CLAUDE_MAX], new Map([["2026-08", { microUsd: 100_000_000 }]]));
|
|
113
|
+
const congelado = (0, amortize_js_1.shareForProject)(enAgosto, "a", ["2026-08"]);
|
|
114
|
+
strict_1.default.equal((0, shared_1.formatMoney)(congelado), "$100,00");
|
|
115
|
+
// En diciembre se conoce el doble de consumo de ese mismo mes.
|
|
116
|
+
const enDiciembre = (0, amortize_js_1.amortize)([{ projectId: "a", month: "2026-08", consumption: { microUsd: 50_000_000 } }], [CLAUDE_MAX], new Map([["2026-08", { microUsd: 200_000_000 }]]));
|
|
117
|
+
const recalculado = (0, amortize_js_1.shareForProject)(enDiciembre, "a", ["2026-08"]);
|
|
118
|
+
strict_1.default.equal((0, shared_1.formatMoney)(recalculado), "$50,00");
|
|
119
|
+
strict_1.default.notEqual(congelado.amount, recalculado.amount, "el recálculo cambia: por eso hay que guardar el de su día");
|
|
120
|
+
});
|
|
121
|
+
//# sourceMappingURL=amortize.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amortize.test.js","sourceRoot":"","sources":["../../src/billing/amortize.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.test.d.ts","sourceRoot":"","sources":["../../src/billing/format.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = require("node:test");
|
|
8
|
+
const shared_1 = require("@estela/shared");
|
|
9
|
+
const sessionize_js_1 = require("./sessionize.js");
|
|
10
|
+
// ── Duraciones ─────────────────────────────────────────────────────────
|
|
11
|
+
(0, node_test_1.test)("nunca imprime 60 minutos", () => {
|
|
12
|
+
// El fallo que esto previene: redondear los minutos después de haber
|
|
13
|
+
// separado las horas. 2h 59m 50s salía como "2h 60m", y en una factura.
|
|
14
|
+
strict_1.default.equal((0, shared_1.formatDuration)(2 * 3600 + 59 * 60 + 50), "3h");
|
|
15
|
+
strict_1.default.equal((0, shared_1.formatDuration)(3599), "1h");
|
|
16
|
+
strict_1.default.equal((0, shared_1.formatDuration)(59 * 60 + 55), "1h");
|
|
17
|
+
});
|
|
18
|
+
(0, node_test_1.test)("duraciones normales", () => {
|
|
19
|
+
strict_1.default.equal((0, shared_1.formatDuration)(0), "0m");
|
|
20
|
+
strict_1.default.equal((0, shared_1.formatDuration)(90), "2m");
|
|
21
|
+
strict_1.default.equal((0, shared_1.formatDuration)(3600), "1h");
|
|
22
|
+
strict_1.default.equal((0, shared_1.formatDuration)(3660), "1h 01m");
|
|
23
|
+
strict_1.default.equal((0, shared_1.formatDuration)(4 * 3600 + 16 * 60), "4h 16m");
|
|
24
|
+
});
|
|
25
|
+
(0, node_test_1.test)("ninguna duración razonable produce 60m", () => {
|
|
26
|
+
for (let s = 0; s < 12 * 3600; s += 7) {
|
|
27
|
+
const out = (0, shared_1.formatDuration)(s);
|
|
28
|
+
strict_1.default.ok(!out.includes("60m"), `formatDuration(${s}) = "${out}"`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// ── Fecha local ────────────────────────────────────────────────────────
|
|
32
|
+
(0, node_test_1.test)("el día de trabajo es el del reloj de quien trabaja", () => {
|
|
33
|
+
// En UTC-5, las 22:00 del día 25 son las 03:00 UTC del 26. Usar la fecha UTC
|
|
34
|
+
// partiría la jornada de tarde-noche entre dos días.
|
|
35
|
+
const tarde = new Date("2026-08-26T03:19:00Z"); // 22:19 del 25 en Ecuador
|
|
36
|
+
const esperado = new Date(tarde.getTime() - tarde.getTimezoneOffset() * 60_000)
|
|
37
|
+
.toISOString().slice(0, 10);
|
|
38
|
+
strict_1.default.equal((0, shared_1.localDate)(tarde), esperado);
|
|
39
|
+
if (tarde.getTimezoneOffset() > 0) {
|
|
40
|
+
strict_1.default.notEqual((0, shared_1.localDate)(tarde), tarde.toISOString().slice(0, 10), "al oeste de Greenwich, la fecha local y la UTC difieren en esta hora");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// ── Agrupación ─────────────────────────────────────────────────────────
|
|
44
|
+
function block(startIso, minutes, branch = "main") {
|
|
45
|
+
const startedAt = new Date(startIso);
|
|
46
|
+
return {
|
|
47
|
+
startedAt,
|
|
48
|
+
endedAt: new Date(startedAt.getTime() + minutes * 60_000),
|
|
49
|
+
seconds: minutes * 60,
|
|
50
|
+
repoPath: "/repo", branch, turnCount: 1,
|
|
51
|
+
aiCost: { microUsd: 1_000_000 },
|
|
52
|
+
models: ["claude-opus-5"], sessionIds: ["s"], commits: [], unpricedModels: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
(0, node_test_1.test)("suma el tiempo trabajado, no el lapso entre el primero y el último", () => {
|
|
56
|
+
// Dos bloques de 30 min con tres horas de comida en medio son 1h, no 4h.
|
|
57
|
+
// Facturar el hueco sería cobrarle de más al cliente.
|
|
58
|
+
const merged = (0, sessionize_js_1.groupByBranchAndDay)([
|
|
59
|
+
block("2026-08-26T14:00:00Z", 30),
|
|
60
|
+
block("2026-08-26T21:00:00Z", 30),
|
|
61
|
+
]);
|
|
62
|
+
strict_1.default.equal(merged.length, 1);
|
|
63
|
+
strict_1.default.equal(merged[0].seconds, 3600, "1h de trabajo real");
|
|
64
|
+
strict_1.default.equal((0, shared_1.formatDuration)(merged[0].seconds), "1h");
|
|
65
|
+
});
|
|
66
|
+
(0, node_test_1.test)("ramas distintas no se fusionan", () => {
|
|
67
|
+
const merged = (0, sessionize_js_1.groupByBranchAndDay)([
|
|
68
|
+
block("2026-08-26T14:00:00Z", 30, "main"),
|
|
69
|
+
block("2026-08-26T15:00:00Z", 30, "feature/auth"),
|
|
70
|
+
]);
|
|
71
|
+
strict_1.default.equal(merged.length, 2);
|
|
72
|
+
});
|
|
73
|
+
(0, node_test_1.test)("se acumulan commits, modelos y coste al fusionar", () => {
|
|
74
|
+
const a = block("2026-08-26T14:00:00Z", 30);
|
|
75
|
+
const b = block("2026-08-26T15:00:00Z", 30);
|
|
76
|
+
const merged = (0, sessionize_js_1.groupByBranchAndDay)([a, b]);
|
|
77
|
+
strict_1.default.equal(merged[0].aiCost.microUsd, 2_000_000);
|
|
78
|
+
strict_1.default.equal(merged[0].turnCount, 2);
|
|
79
|
+
});
|
|
80
|
+
// ── Identificador determinista de una imputación ───────────────────────
|
|
81
|
+
/** La misma fórmula que usan la CLI y el servidor para nombrar un bloque. */
|
|
82
|
+
function entryId(projectId, startedAt, branch) {
|
|
83
|
+
const day = (0, shared_1.localDate)(startedAt);
|
|
84
|
+
const slug = (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
|
|
85
|
+
return `te_${projectId}_${day}_${slug}`;
|
|
86
|
+
}
|
|
87
|
+
(0, node_test_1.test)("el id usa la fecha local, no la UTC", () => {
|
|
88
|
+
// El fallo que esto previene: la CLI construía el id con toISOString() (UTC)
|
|
89
|
+
// y el servidor con localDate(). Para el mismo bloque salían dos ids, así que
|
|
90
|
+
// en vez de actualizar una fila se creaba otra y el día se contaba doble.
|
|
91
|
+
const tarde = new Date("2026-08-27T02:49:59Z"); // 21:49 del 26 en UTC-5
|
|
92
|
+
const id = entryId("euro", tarde, "feature/x");
|
|
93
|
+
strict_1.default.ok(id.includes((0, shared_1.localDate)(tarde)), "el id lleva la fecha local");
|
|
94
|
+
if (tarde.getTimezoneOffset() > 0) {
|
|
95
|
+
strict_1.default.ok(!id.includes(tarde.toISOString().slice(0, 10)), "y no la UTC, que aquí es el día siguiente");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
(0, node_test_1.test)("el mismo bloque siempre produce el mismo id", () => {
|
|
99
|
+
const at = new Date("2026-08-26T14:00:00Z");
|
|
100
|
+
strict_1.default.equal(entryId("p", at, "main"), entryId("p", new Date(at), "main"));
|
|
101
|
+
});
|
|
102
|
+
(0, node_test_1.test)("el id coincide con el local_date de la fila", () => {
|
|
103
|
+
// La invariante que hace posible detectar filas huérfanas: si un id lleva una
|
|
104
|
+
// fecha distinta a su local_date, esa fila la creó una versión con el bug.
|
|
105
|
+
for (const iso of ["2026-08-27T02:49:00Z", "2026-08-26T14:00:00Z", "2026-01-01T03:00:00Z"]) {
|
|
106
|
+
const at = new Date(iso);
|
|
107
|
+
strict_1.default.ok(entryId("p", at, "main").includes(`_${(0, shared_1.localDate)(at)}_`));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
//# sourceMappingURL=format.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.test.js","sourceRoot":"","sources":["../../src/billing/format.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Client, Invoice, Money, Project, RatePeriod, TimeEntry } from "@estela/shared";
|
|
2
|
+
/**
|
|
3
|
+
* Emisión de facturas.
|
|
4
|
+
*
|
|
5
|
+
* Tres reglas que no se negocian, porque son las que hacen que una factura de
|
|
6
|
+
* hace seis meses siga cuadrando hoy:
|
|
7
|
+
*
|
|
8
|
+
* 1. La tarifa se busca por la fecha del trabajo, no por la de hoy. Si subes
|
|
9
|
+
* el precio a mitad de proyecto, lo ya trabajado mantiene el suyo.
|
|
10
|
+
* 2. El tipo de cambio se captura al emitir y se guarda en la factura. Jamás
|
|
11
|
+
* se recalcula con el del día.
|
|
12
|
+
* 3. El coste de IA está en USD porque es la moneda en la que factura el
|
|
13
|
+
* proveedor. Nunca se suma a un importe en otra moneda sin un tipo
|
|
14
|
+
* explícito.
|
|
15
|
+
*/
|
|
16
|
+
export interface IssueInvoiceInput {
|
|
17
|
+
readonly client: Client;
|
|
18
|
+
readonly project: Project;
|
|
19
|
+
readonly rates: readonly RatePeriod[];
|
|
20
|
+
readonly entries: readonly TimeEntry[];
|
|
21
|
+
/** Fecha de corte, inclusive. Se factura todo lo anterior o igual. */
|
|
22
|
+
readonly cutoffAt: Date;
|
|
23
|
+
readonly number: string;
|
|
24
|
+
readonly issuedAt?: Date;
|
|
25
|
+
/**
|
|
26
|
+
* Unidades de la moneda del cliente por 1 USD. Obligatorio solo cuando el
|
|
27
|
+
* proyecto repercute el coste de IA y la moneda no es USD.
|
|
28
|
+
*/
|
|
29
|
+
readonly usdFxRate?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Parte de la cuota de suscripción que le toca a este periodo. Se calcula
|
|
32
|
+
* fuera y se guarda tal cual: es un valor derivado que cambia cuando entran
|
|
33
|
+
* más datos, así que el informe tiene que quedarse con el de su día.
|
|
34
|
+
*/
|
|
35
|
+
readonly aiAmortized?: Money;
|
|
36
|
+
readonly notes?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare class InvoiceError extends Error {
|
|
39
|
+
}
|
|
40
|
+
export declare function issueInvoice(input: IssueInvoiceInput): Invoice;
|
|
41
|
+
/** Tarifa vigente en una fecha. Las tarifas se apilan, no se sobrescriben. */
|
|
42
|
+
export declare function rateAt(rates: readonly RatePeriod[], projectId: string, at: Date): Money | null;
|
|
43
|
+
/** Renderiza la factura como texto plano para revisarla antes de enviarla. */
|
|
44
|
+
export declare function renderInvoice(invoice: Invoice, client: Client, project: Project): string;
|
|
45
|
+
/** Margen real del periodo: lo cobrado menos lo que costó la IA. */
|
|
46
|
+
export declare function marginOf(invoice: Invoice, usdFxRate: number): {
|
|
47
|
+
revenue: Money;
|
|
48
|
+
aiCost: Money;
|
|
49
|
+
margin: Money;
|
|
50
|
+
marginPct: number;
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=invoice.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invoice.d.ts","sourceRoot":"","sources":["../../src/billing/invoice.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACF,MAAM,EAAE,OAAO,EAAe,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,SAAS,EAC5E,MAAM,gBAAgB,CAAC;AAMxB;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,SAAS,SAAS,EAAE,CAAC;IACvC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,qBAAa,YAAa,SAAQ,KAAK;CAAG;AAE1C,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAwF9D;AAED,8EAA8E;AAC9E,wBAAgB,MAAM,CACpB,KAAK,EAAE,SAAS,UAAU,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,IAAI,GACP,KAAK,GAAG,IAAI,CAOd;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAgDxF;AAED,oEAAoE;AACpE,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG;IAC7D,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;CACjE,CAUA"}
|