sii-cli 0.1.0__py3-none-any.whl
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.
- sii/__init__.py +1 -0
- sii/cli/__init__.py +3 -0
- sii/cli/main.py +925 -0
- sii/core/__init__.py +3 -0
- sii/core/auth/__init__.py +10 -0
- sii/core/auth/cert.py +13 -0
- sii/core/auth/clave_unica.py +6 -0
- sii/core/auth/portal_session.py +528 -0
- sii/core/config.py +68 -0
- sii/core/portal/__init__.py +27 -0
- sii/core/portal/_common.py +166 -0
- sii/core/portal/f29.py +350 -0
- sii/core/portal/rcv.py +1354 -0
- sii/core/rut.py +122 -0
- sii/core/storage/__init__.py +8 -0
- sii/core/storage/audit_log.py +131 -0
- sii/core/storage/credentials.py +271 -0
- sii/core/tasks/__init__.py +71 -0
- sii/core/tasks/_datos_cntr.py +153 -0
- sii/core/tasks/auth.py +480 -0
- sii/core/tasks/datos_contribuyente.py +474 -0
- sii/mcp/__init__.py +0 -0
- sii/mcp/__main__.py +3 -0
- sii/mcp/server.py +348 -0
- sii_cli-0.1.0.dist-info/METADATA +125 -0
- sii_cli-0.1.0.dist-info/RECORD +29 -0
- sii_cli-0.1.0.dist-info/WHEEL +4 -0
- sii_cli-0.1.0.dist-info/entry_points.txt +4 -0
- sii_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
sii/cli/main.py
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from sii import __version__
|
|
11
|
+
from sii.core import get_settings
|
|
12
|
+
from sii.core.auth.portal_session import (
|
|
13
|
+
LoginFailedError,
|
|
14
|
+
NotAuthenticatedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
)
|
|
17
|
+
from sii.core.config import DTE_WS, LOGIN_URL, PORTAL
|
|
18
|
+
from sii.core.storage.credentials import CredentialNotFoundError
|
|
19
|
+
from sii.core.tasks import (
|
|
20
|
+
AuthStatusRefresh,
|
|
21
|
+
DatosContribuyente,
|
|
22
|
+
F29Propuesta,
|
|
23
|
+
RcvDetalle,
|
|
24
|
+
RcvMatchResult,
|
|
25
|
+
RcvResumen,
|
|
26
|
+
RcvResumenRange,
|
|
27
|
+
RcvSide,
|
|
28
|
+
auth_login as _auth_login,
|
|
29
|
+
auth_logout as _auth_logout,
|
|
30
|
+
auth_status as _auth_status,
|
|
31
|
+
auth_status_refresh as _auth_status_refresh,
|
|
32
|
+
consultar_datos_contribuyente,
|
|
33
|
+
consultar_f29_propuesta,
|
|
34
|
+
consultar_rcv_detalle,
|
|
35
|
+
consultar_rcv_match,
|
|
36
|
+
consultar_rcv_resumen,
|
|
37
|
+
consultar_rcv_resumen_range,
|
|
38
|
+
consultar_rcv_resumen_year,
|
|
39
|
+
local_session_state as _local_session_state,
|
|
40
|
+
redact,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
name="sii",
|
|
45
|
+
help="CLI para automatizar interacciones con el SII Chile.",
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
rcv_app = typer.Typer(
|
|
50
|
+
name="rcv",
|
|
51
|
+
help="Registro de Compras y Ventas — listado de facturas y boletas por período.",
|
|
52
|
+
no_args_is_help=True,
|
|
53
|
+
)
|
|
54
|
+
app.add_typer(rcv_app, name="rcv")
|
|
55
|
+
|
|
56
|
+
f29_app = typer.Typer(
|
|
57
|
+
name="f29",
|
|
58
|
+
help="Declaración Mensual de IVA (F29) — read SII's pre-filled propuesta.",
|
|
59
|
+
no_args_is_help=True,
|
|
60
|
+
)
|
|
61
|
+
app.add_typer(f29_app, name="f29")
|
|
62
|
+
|
|
63
|
+
auth_app = typer.Typer(
|
|
64
|
+
name="auth",
|
|
65
|
+
help="Authentication — explicit verbs (status / login / logout). Decoupled "
|
|
66
|
+
"from domain tasks so a long-lived MCP server can warm up its session "
|
|
67
|
+
"deliberately.",
|
|
68
|
+
no_args_is_help=True,
|
|
69
|
+
)
|
|
70
|
+
app.add_typer(auth_app, name="auth")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _OutputFormat(str, Enum):
|
|
74
|
+
json = "json"
|
|
75
|
+
table = "table"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def status() -> None:
|
|
80
|
+
"""Show the resolved hostnames and rate limit (sanity check)."""
|
|
81
|
+
s = get_settings()
|
|
82
|
+
typer.echo(f"dte_ws: {DTE_WS}")
|
|
83
|
+
typer.echo(f"portal: {PORTAL}")
|
|
84
|
+
typer.echo(f"login: {LOGIN_URL}")
|
|
85
|
+
typer.echo(f"rate_limit: {s.rate_limit_rps} req/s per RUT")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def version() -> None:
|
|
90
|
+
"""Print the installed sii package version."""
|
|
91
|
+
typer.echo(__version__)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _print_datos_json(d: DatosContribuyente) -> None:
|
|
95
|
+
typer.echo(json.dumps(asdict(d), indent=2, ensure_ascii=False))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _print_datos_table(d: DatosContribuyente) -> None:
|
|
99
|
+
"""Curated subset for humans: identidad + clasificación + actividad +
|
|
100
|
+
contacto + dirección principal. Use --format json for the full snapshot."""
|
|
101
|
+
typer.echo(f"rut: {d.rut}")
|
|
102
|
+
c = d.contribuyente
|
|
103
|
+
if c is not None:
|
|
104
|
+
nombre = (
|
|
105
|
+
c.razon_social
|
|
106
|
+
or " ".join(p for p in (c.nombres, c.apellido_paterno, c.apellido_materno) if p)
|
|
107
|
+
or None
|
|
108
|
+
)
|
|
109
|
+
if nombre:
|
|
110
|
+
typer.echo(f"nombre: {nombre}")
|
|
111
|
+
if c.tipo_contribuyente_descripcion:
|
|
112
|
+
typer.echo(f"tipo contribuyente: {c.tipo_contribuyente_descripcion}")
|
|
113
|
+
if c.segmento_descripcion:
|
|
114
|
+
typer.echo(f"segmento: {c.segmento_descripcion}")
|
|
115
|
+
if c.glosa_actividad:
|
|
116
|
+
typer.echo(f"glosa actividad: {c.glosa_actividad}")
|
|
117
|
+
if c.fecha_inicio_actividades:
|
|
118
|
+
typer.echo(f"fecha inicio actividades: {c.fecha_inicio_actividades}")
|
|
119
|
+
if c.fecha_nacimiento:
|
|
120
|
+
typer.echo(f"fecha nacimiento: {c.fecha_nacimiento}")
|
|
121
|
+
if c.email:
|
|
122
|
+
typer.echo(f"email: {c.email}")
|
|
123
|
+
if c.telefono_movil:
|
|
124
|
+
typer.echo(f"teléfono móvil: {c.telefono_movil}")
|
|
125
|
+
if c.unidad_operativa_descripcion:
|
|
126
|
+
typer.echo(f"unidad operativa SII: {c.unidad_operativa_descripcion}")
|
|
127
|
+
if c.unidad_operativa_direccion:
|
|
128
|
+
typer.echo(f"unidad operativa dir.: {c.unidad_operativa_direccion}")
|
|
129
|
+
if d.direcciones:
|
|
130
|
+
principal = d.direcciones[0]
|
|
131
|
+
if principal.calle:
|
|
132
|
+
comuna = principal.comuna_descripcion or ""
|
|
133
|
+
sep = ", " if comuna else ""
|
|
134
|
+
typer.echo(f"dirección principal: {principal.calle}{sep}{comuna}")
|
|
135
|
+
if d.atributos:
|
|
136
|
+
typer.echo(f"atributos: {len(d.atributos)}")
|
|
137
|
+
for a in d.atributos:
|
|
138
|
+
tag = a.atr_codigo or "?"
|
|
139
|
+
desc = a.desc_atr_codigo or ""
|
|
140
|
+
typer.echo(f" - {tag}: {desc}")
|
|
141
|
+
typer.echo(f"fuente: {d.portal_url}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def profile(
|
|
146
|
+
fmt: _OutputFormat = typer.Option(
|
|
147
|
+
_OutputFormat.json,
|
|
148
|
+
"--format",
|
|
149
|
+
"-f",
|
|
150
|
+
case_sensitive=False,
|
|
151
|
+
help="Output format. JSON (default) for machine consumption / piping to jq; "
|
|
152
|
+
"table for a curated human readout.",
|
|
153
|
+
),
|
|
154
|
+
redact_pii: bool = typer.Option(
|
|
155
|
+
False,
|
|
156
|
+
"--redact",
|
|
157
|
+
help="Mask email, phone, address, DoB, and unidad-operativa address. RUT is NOT redacted.",
|
|
158
|
+
),
|
|
159
|
+
dump_html: Path | None = typer.Option(
|
|
160
|
+
None,
|
|
161
|
+
"--dump-html",
|
|
162
|
+
help="Write the full authenticated-landing HTML to this path "
|
|
163
|
+
"(WARNING: contains real PII — never commit or share).",
|
|
164
|
+
),
|
|
165
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Authenticate and return the FULL DatosCntrNow contribuyente snapshot.
|
|
168
|
+
|
|
169
|
+
Includes PII (email, phone, address, DoB). Use `sii auth status
|
|
170
|
+
--refresh` for the safe subset. Pass `--redact` to mask the
|
|
171
|
+
sensitive fields.
|
|
172
|
+
"""
|
|
173
|
+
if verbose:
|
|
174
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
175
|
+
try:
|
|
176
|
+
d = asyncio.run(consultar_datos_contribuyente(dump_html_to=dump_html))
|
|
177
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
178
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
179
|
+
raise typer.Exit(code=2)
|
|
180
|
+
except LoginFailedError as exc:
|
|
181
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
182
|
+
raise typer.Exit(code=3)
|
|
183
|
+
except RateLimitError as exc:
|
|
184
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
185
|
+
raise typer.Exit(code=4)
|
|
186
|
+
if redact_pii:
|
|
187
|
+
d = redact(d)
|
|
188
|
+
if fmt is _OutputFormat.json:
|
|
189
|
+
_print_datos_json(d)
|
|
190
|
+
else:
|
|
191
|
+
_print_datos_table(d)
|
|
192
|
+
if dump_html is not None:
|
|
193
|
+
typer.echo(f"html: {dump_html}", err=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _print_refresh_table(r: AuthStatusRefresh) -> None:
|
|
197
|
+
"""Curated human view of the identity snapshot. Mirrors the field
|
|
198
|
+
order the previous `sii whoami` table used so muscle memory survives
|
|
199
|
+
the surface rename (ADR-018)."""
|
|
200
|
+
typer.echo(f"rut: {r.rut}")
|
|
201
|
+
if r.nombre:
|
|
202
|
+
typer.echo(f"nombre: {r.nombre}")
|
|
203
|
+
if r.tipo_contribuyente:
|
|
204
|
+
typer.echo(f"tipo contribuyente: {r.tipo_contribuyente}")
|
|
205
|
+
if r.segmento:
|
|
206
|
+
typer.echo(f"segmento: {r.segmento}")
|
|
207
|
+
if r.glosa_actividad:
|
|
208
|
+
typer.echo(f"glosa actividad: {r.glosa_actividad}")
|
|
209
|
+
if r.fecha_inicio_actividades:
|
|
210
|
+
typer.echo(f"fecha inicio actividades: {r.fecha_inicio_actividades}")
|
|
211
|
+
if r.unidad_operativa:
|
|
212
|
+
typer.echo(f"unidad operativa SII: {r.unidad_operativa}")
|
|
213
|
+
typer.echo(f"fuente: {r.portal_url}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@auth_app.command(name="status")
|
|
217
|
+
def auth_status_cmd(
|
|
218
|
+
refresh: bool = typer.Option(
|
|
219
|
+
False,
|
|
220
|
+
"--refresh",
|
|
221
|
+
"-r",
|
|
222
|
+
help="Hit the portal and read the live identity snapshot from "
|
|
223
|
+
"DatosCntrNow (RUT + nombre + tipo + segmento + glosa actividad + "
|
|
224
|
+
"fecha inicio actividades + unidad operativa). Requires an active "
|
|
225
|
+
"session — raises an error if not logged in (no implicit login).",
|
|
226
|
+
),
|
|
227
|
+
fmt: _OutputFormat = typer.Option(
|
|
228
|
+
_OutputFormat.json,
|
|
229
|
+
"--format",
|
|
230
|
+
"-f",
|
|
231
|
+
case_sensitive=False,
|
|
232
|
+
help="Output format. JSON (default) for machine consumption; table for a human readout.",
|
|
233
|
+
),
|
|
234
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Report session state. Local cache by default; `--refresh` hits SII.
|
|
237
|
+
|
|
238
|
+
Without `--refresh`: pure local read of `~/.sii/session.json` +
|
|
239
|
+
`~/.sii/session.meta`. Output: `authenticated`, `rut`,
|
|
240
|
+
`session_source`. No network call.
|
|
241
|
+
|
|
242
|
+
With `--refresh`: launches Playwright, reads the authenticated Mi
|
|
243
|
+
Sii landing, parses `DatosCntrNow`, and returns the curated
|
|
244
|
+
identity surface. Replaces the deleted `sii whoami` per ADR-018.
|
|
245
|
+
"""
|
|
246
|
+
if verbose:
|
|
247
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
248
|
+
if refresh:
|
|
249
|
+
try:
|
|
250
|
+
r = asyncio.run(_auth_status_refresh())
|
|
251
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
252
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
253
|
+
raise typer.Exit(code=2)
|
|
254
|
+
except LoginFailedError as exc:
|
|
255
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
256
|
+
raise typer.Exit(code=3)
|
|
257
|
+
except RateLimitError as exc:
|
|
258
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
259
|
+
raise typer.Exit(code=4)
|
|
260
|
+
if fmt is _OutputFormat.json:
|
|
261
|
+
typer.echo(json.dumps(asdict(r), indent=2, ensure_ascii=False))
|
|
262
|
+
else:
|
|
263
|
+
_print_refresh_table(r)
|
|
264
|
+
return
|
|
265
|
+
s = asyncio.run(_auth_status())
|
|
266
|
+
if fmt is _OutputFormat.json:
|
|
267
|
+
typer.echo(json.dumps(asdict(s), indent=2, ensure_ascii=False))
|
|
268
|
+
else:
|
|
269
|
+
typer.echo(f"authenticated: {str(s.authenticated).lower()}")
|
|
270
|
+
typer.echo(f"rut: {s.rut or '-'}")
|
|
271
|
+
typer.echo(f"session_source: {s.session_source}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@auth_app.command(name="login")
|
|
275
|
+
def auth_login_cmd(
|
|
276
|
+
force: bool = typer.Option(
|
|
277
|
+
False,
|
|
278
|
+
"--force",
|
|
279
|
+
help="Wipe any existing session + keyring entry first, then start a "
|
|
280
|
+
"fresh interactive login. Use when you want to switch accounts or "
|
|
281
|
+
"reset a stuck state.",
|
|
282
|
+
),
|
|
283
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Interactively authenticate against SII. See ADR-018.
|
|
286
|
+
|
|
287
|
+
No flags: prompts for RUT + Clave Tributaria, submits to SII,
|
|
288
|
+
persists the credential to OS keyring, and writes a session cache.
|
|
289
|
+
If a valid session is already active and `--force` is NOT set, the
|
|
290
|
+
command is idempotent: prints "already authenticated as <rut>" and
|
|
291
|
+
exits without prompting OR submitting.
|
|
292
|
+
|
|
293
|
+
Single-account: any prior credential is replaced. To switch
|
|
294
|
+
accounts, run `sii auth logout` then `sii auth login` again — or
|
|
295
|
+
just `sii auth login --force` which combines both.
|
|
296
|
+
|
|
297
|
+
The password is read via `getpass`-style hidden input — never on
|
|
298
|
+
the command line as a flag, never echoed to the terminal, never
|
|
299
|
+
recorded in shell history.
|
|
300
|
+
"""
|
|
301
|
+
if verbose:
|
|
302
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
303
|
+
|
|
304
|
+
# Decide whether we need to prompt. `--force` always prompts.
|
|
305
|
+
# Otherwise, a pure local-cache read tells us if the task layer's
|
|
306
|
+
# idempotent fast-path can take over (no submit, no prompt) —
|
|
307
|
+
# using `local_session_state()` instead of `auth_status()` so the
|
|
308
|
+
# probe doesn't write a forensic audit entry every invocation.
|
|
309
|
+
need_prompt = force
|
|
310
|
+
if not force:
|
|
311
|
+
try:
|
|
312
|
+
authenticated, _ = _local_session_state()
|
|
313
|
+
if not authenticated:
|
|
314
|
+
need_prompt = True
|
|
315
|
+
except Exception:
|
|
316
|
+
need_prompt = True
|
|
317
|
+
|
|
318
|
+
rut_arg: str | None = None
|
|
319
|
+
password_arg: str | None = None
|
|
320
|
+
if need_prompt:
|
|
321
|
+
# Method prompt deferred: only RUT + Clave Tributaria is wired.
|
|
322
|
+
# The .pfx certificate path lands when DTE work starts (per
|
|
323
|
+
# ADR-018 Decision section).
|
|
324
|
+
typer.echo("Method: (1) RUT + Clave Tributaria")
|
|
325
|
+
rut_arg = typer.prompt("RUT").strip()
|
|
326
|
+
password_arg = typer.prompt("Clave Tributaria", hide_input=True)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
r = asyncio.run(_auth_login(rut=rut_arg, password=password_arg, force=force))
|
|
330
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
331
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
332
|
+
raise typer.Exit(code=2)
|
|
333
|
+
except LoginFailedError as exc:
|
|
334
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
335
|
+
raise typer.Exit(code=3)
|
|
336
|
+
except RateLimitError as exc:
|
|
337
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
338
|
+
raise typer.Exit(code=4)
|
|
339
|
+
|
|
340
|
+
if r.reason == "already_authenticated":
|
|
341
|
+
typer.echo(f"already authenticated as {r.rut}")
|
|
342
|
+
elif r.reason == "forced":
|
|
343
|
+
typer.echo(f"forced re-login complete; authenticated as {r.rut}")
|
|
344
|
+
else:
|
|
345
|
+
typer.echo(f"authenticated as {r.rut}; credential saved to keyring")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@auth_app.command(name="logout")
|
|
349
|
+
def auth_logout_cmd(verbose: bool = typer.Option(False, "--verbose", "-v")) -> None:
|
|
350
|
+
"""Close the active SII session (clears local + best-effort server-side close).
|
|
351
|
+
|
|
352
|
+
Per ADR-011, keeps stale-session accumulation under SII's ~1-hour
|
|
353
|
+
block threshold. Replaces the previous flat `sii logout` command.
|
|
354
|
+
"""
|
|
355
|
+
if verbose:
|
|
356
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
357
|
+
had_session = asyncio.run(_auth_logout())
|
|
358
|
+
if had_session:
|
|
359
|
+
typer.echo("sesión cerrada (local).")
|
|
360
|
+
else:
|
|
361
|
+
typer.echo("no había sesión activa.")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _print_rcv_resumen_json(r: RcvResumen) -> None:
|
|
365
|
+
typer.echo(json.dumps(asdict(r), indent=2, ensure_ascii=False))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _print_rcv_resumen_table(r: RcvResumen) -> None:
|
|
369
|
+
"""Curated human view: header, per-DTE-type rows, totals at the bottom."""
|
|
370
|
+
typer.echo(f"rut: {r.rut}")
|
|
371
|
+
typer.echo(f"period: {r.period}")
|
|
372
|
+
typer.echo(f"side: {r.side}")
|
|
373
|
+
typer.echo("")
|
|
374
|
+
if not r.rows:
|
|
375
|
+
typer.echo("(no rows for this period)")
|
|
376
|
+
if r.totals is None:
|
|
377
|
+
return
|
|
378
|
+
else:
|
|
379
|
+
typer.echo(
|
|
380
|
+
f"{'cod':<5} {'descripción':<40} {'docs':>6} "
|
|
381
|
+
f"{'exento':>12} {'neto':>12} {'iva':>12} {'total':>12}"
|
|
382
|
+
)
|
|
383
|
+
typer.echo("-" * 110)
|
|
384
|
+
for row in r.rows:
|
|
385
|
+
typer.echo(
|
|
386
|
+
f"{row.codigo_tipo_doc or '-':<5} "
|
|
387
|
+
f"{(row.descripcion or '-')[:40]:<40} "
|
|
388
|
+
f"{row.total_documentos if row.total_documentos is not None else '-':>6} "
|
|
389
|
+
f"{row.monto_exento if row.monto_exento is not None else '-':>12} "
|
|
390
|
+
f"{row.monto_neto if row.monto_neto is not None else '-':>12} "
|
|
391
|
+
f"{row.monto_iva if row.monto_iva is not None else '-':>12} "
|
|
392
|
+
f"{row.monto_total if row.monto_total is not None else '-':>12}"
|
|
393
|
+
)
|
|
394
|
+
if r.totals is not None:
|
|
395
|
+
t = r.totals
|
|
396
|
+
typer.echo("-" * 110)
|
|
397
|
+
typer.echo(
|
|
398
|
+
f"{'TOT':<5} {'':<40} "
|
|
399
|
+
f"{t.total_documentos if t.total_documentos is not None else '-':>6} "
|
|
400
|
+
f"{t.monto_exento if t.monto_exento is not None else '-':>12} "
|
|
401
|
+
f"{t.monto_neto if t.monto_neto is not None else '-':>12} "
|
|
402
|
+
f"{t.monto_iva if t.monto_iva is not None else '-':>12} "
|
|
403
|
+
f"{t.monto_total if t.monto_total is not None else '-':>12}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _print_rcv_resumen_range_json(r: RcvResumenRange) -> None:
|
|
408
|
+
typer.echo(json.dumps(asdict(r), indent=2, ensure_ascii=False))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _print_rcv_resumen_range_table(r: RcvResumenRange) -> None:
|
|
412
|
+
"""Curated human view: aggregated rows at top, per-month breakdown
|
|
413
|
+
footer. Mirrors `_print_rcv_resumen_table` formatting for visual
|
|
414
|
+
consistency."""
|
|
415
|
+
typer.echo(f"rut: {r.rut}")
|
|
416
|
+
typer.echo(f"start period: {r.start_period}")
|
|
417
|
+
typer.echo(f"end period: {r.end_period}")
|
|
418
|
+
typer.echo(f"side: {r.side}")
|
|
419
|
+
typer.echo(f"months: {len(r.months)}")
|
|
420
|
+
typer.echo("")
|
|
421
|
+
typer.echo("=== AGGREGATED (sum across all months) ===")
|
|
422
|
+
if not r.aggregated:
|
|
423
|
+
typer.echo("(no rows for any period in this range)")
|
|
424
|
+
else:
|
|
425
|
+
typer.echo(
|
|
426
|
+
f"{'cod':<5} {'descripción':<40} {'docs':>6} "
|
|
427
|
+
f"{'exento':>12} {'neto':>12} {'iva':>12} {'total':>12}"
|
|
428
|
+
)
|
|
429
|
+
typer.echo("-" * 110)
|
|
430
|
+
for row in r.aggregated:
|
|
431
|
+
typer.echo(
|
|
432
|
+
f"{row.codigo_tipo_doc or '-':<5} "
|
|
433
|
+
f"{(row.descripcion or '-')[:40]:<40} "
|
|
434
|
+
f"{row.total_documentos if row.total_documentos is not None else '-':>6} "
|
|
435
|
+
f"{row.monto_exento if row.monto_exento is not None else '-':>12} "
|
|
436
|
+
f"{row.monto_neto if row.monto_neto is not None else '-':>12} "
|
|
437
|
+
f"{row.monto_iva if row.monto_iva is not None else '-':>12} "
|
|
438
|
+
f"{row.monto_total if row.monto_total is not None else '-':>12}"
|
|
439
|
+
)
|
|
440
|
+
typer.echo("")
|
|
441
|
+
typer.echo("=== PER-MONTH BREAKDOWN ===")
|
|
442
|
+
typer.echo(f"{'period':<8} {'cods':>4} {'docs':>6} {'total':>14}")
|
|
443
|
+
typer.echo("-" * 40)
|
|
444
|
+
for m in r.months:
|
|
445
|
+
if not m.rows:
|
|
446
|
+
typer.echo(f"{m.period:<8} {'-':>4} {'-':>6} {'(empty)':>14}")
|
|
447
|
+
continue
|
|
448
|
+
docs = sum(row.total_documentos or 0 for row in m.rows)
|
|
449
|
+
total = sum(row.monto_total or 0 for row in m.rows)
|
|
450
|
+
typer.echo(f"{m.period:<8} {len(m.rows):>4} {docs:>6} {total:>14}")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@rcv_app.command()
|
|
454
|
+
def summary(
|
|
455
|
+
period: str | None = typer.Option(
|
|
456
|
+
None, "--period", help="Single tax period in YYYY-MM (e.g. 2026-06)."
|
|
457
|
+
),
|
|
458
|
+
year: int | None = typer.Option(
|
|
459
|
+
None,
|
|
460
|
+
"--year",
|
|
461
|
+
help="Year to query as a single range (YYYY-01 .. YYYY-12). Mutually "
|
|
462
|
+
"exclusive with --period and --from/--to.",
|
|
463
|
+
),
|
|
464
|
+
range_from: str | None = typer.Option(
|
|
465
|
+
None,
|
|
466
|
+
"--from",
|
|
467
|
+
help="Range start (inclusive) in YYYY-MM. Requires --to. Mutually "
|
|
468
|
+
"exclusive with --period and --year.",
|
|
469
|
+
),
|
|
470
|
+
range_to: str | None = typer.Option(
|
|
471
|
+
None,
|
|
472
|
+
"--to",
|
|
473
|
+
help="Range end (inclusive) in YYYY-MM. Requires --from.",
|
|
474
|
+
),
|
|
475
|
+
side: RcvSide = typer.Option(
|
|
476
|
+
...,
|
|
477
|
+
"--side",
|
|
478
|
+
case_sensitive=False,
|
|
479
|
+
help="COMPRA (DTEs you received) or VENTA (DTEs you issued).",
|
|
480
|
+
),
|
|
481
|
+
rut: str | None = typer.Option(
|
|
482
|
+
None,
|
|
483
|
+
"--rut",
|
|
484
|
+
help="Operating RUT (e.g. 12345670-K). Defaults to the authenticated "
|
|
485
|
+
"RUT; see ADR-015 for the multi-RUT model.",
|
|
486
|
+
),
|
|
487
|
+
fmt: _OutputFormat = typer.Option(
|
|
488
|
+
_OutputFormat.json,
|
|
489
|
+
"--format",
|
|
490
|
+
"-f",
|
|
491
|
+
case_sensitive=False,
|
|
492
|
+
help="Output format. JSON (default) for machine consumption / piping to jq; "
|
|
493
|
+
"table for a curated human readout.",
|
|
494
|
+
),
|
|
495
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Per-DTE-type summary of the RCV. Three mutually-exclusive modes.
|
|
498
|
+
|
|
499
|
+
`--period YYYY-MM` — single period (original behavior).
|
|
500
|
+
`--year YYYY` — January through December of the year.
|
|
501
|
+
`--from YYYY-MM --to YYYY-MM` — inclusive arbitrary range.
|
|
502
|
+
|
|
503
|
+
Multi-period modes loop ONE `logged_in_page()` across N months
|
|
504
|
+
(ADR-009) and pace consecutive POSTs via `asyncio.sleep(1.0 /
|
|
505
|
+
settings.rate_limit_rps)` (ADR-011). Per-month failures are isolated
|
|
506
|
+
— month K raising does NOT abort the rest; the failed month surfaces
|
|
507
|
+
with empty rows and the summary entry records `result="partial"`.
|
|
508
|
+
"""
|
|
509
|
+
if verbose:
|
|
510
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
511
|
+
|
|
512
|
+
# Mutex: exactly one of {period, year, (range_from + range_to)}.
|
|
513
|
+
# `--from` and `--to` must come together to make sense.
|
|
514
|
+
has_period = period is not None
|
|
515
|
+
has_year = year is not None
|
|
516
|
+
has_from = range_from is not None
|
|
517
|
+
has_to = range_to is not None
|
|
518
|
+
if has_from ^ has_to:
|
|
519
|
+
typer.secho(
|
|
520
|
+
"invalid arguments: --from and --to must be supplied together.",
|
|
521
|
+
fg=typer.colors.RED,
|
|
522
|
+
err=True,
|
|
523
|
+
)
|
|
524
|
+
raise typer.Exit(code=2)
|
|
525
|
+
has_range = has_from and has_to
|
|
526
|
+
modes = sum([has_period, has_year, has_range])
|
|
527
|
+
if modes == 0:
|
|
528
|
+
typer.secho(
|
|
529
|
+
"invalid arguments: one of --period, --year, or --from/--to is required.",
|
|
530
|
+
fg=typer.colors.RED,
|
|
531
|
+
err=True,
|
|
532
|
+
)
|
|
533
|
+
raise typer.Exit(code=2)
|
|
534
|
+
if modes > 1:
|
|
535
|
+
typer.secho(
|
|
536
|
+
"invalid arguments: --period, --year, and --from/--to are mutually exclusive — pick one.",
|
|
537
|
+
fg=typer.colors.RED,
|
|
538
|
+
err=True,
|
|
539
|
+
)
|
|
540
|
+
raise typer.Exit(code=2)
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
if has_period:
|
|
544
|
+
assert period is not None # mypy
|
|
545
|
+
r: RcvResumen | RcvResumenRange = asyncio.run(
|
|
546
|
+
consultar_rcv_resumen(period=period, side=side, rut_operativo=rut)
|
|
547
|
+
)
|
|
548
|
+
elif has_year:
|
|
549
|
+
assert year is not None # mypy
|
|
550
|
+
r = asyncio.run(consultar_rcv_resumen_year(year, side=side, rut_operativo=rut))
|
|
551
|
+
else:
|
|
552
|
+
assert range_from is not None and range_to is not None # mypy
|
|
553
|
+
r = asyncio.run(
|
|
554
|
+
consultar_rcv_resumen_range(
|
|
555
|
+
start_period=range_from,
|
|
556
|
+
end_period=range_to,
|
|
557
|
+
side=side,
|
|
558
|
+
rut_operativo=rut,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
562
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
563
|
+
raise typer.Exit(code=2)
|
|
564
|
+
except LoginFailedError as exc:
|
|
565
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
566
|
+
raise typer.Exit(code=3)
|
|
567
|
+
except RateLimitError as exc:
|
|
568
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
569
|
+
raise typer.Exit(code=4)
|
|
570
|
+
except ValueError as exc:
|
|
571
|
+
typer.secho(f"invalid argument: {exc}", fg=typer.colors.RED, err=True)
|
|
572
|
+
raise typer.Exit(code=2)
|
|
573
|
+
|
|
574
|
+
if isinstance(r, RcvResumenRange):
|
|
575
|
+
if fmt is _OutputFormat.json:
|
|
576
|
+
_print_rcv_resumen_range_json(r)
|
|
577
|
+
else:
|
|
578
|
+
_print_rcv_resumen_range_table(r)
|
|
579
|
+
else:
|
|
580
|
+
if fmt is _OutputFormat.json:
|
|
581
|
+
_print_rcv_resumen_json(r)
|
|
582
|
+
else:
|
|
583
|
+
_print_rcv_resumen_table(r)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _print_rcv_detalle_json(d: RcvDetalle) -> None:
|
|
587
|
+
typer.echo(json.dumps(asdict(d), indent=2, ensure_ascii=False))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _print_rcv_detalle_table(d: RcvDetalle) -> None:
|
|
591
|
+
"""Curated human view: header + one row per DTE. Tax-special fields
|
|
592
|
+
(activo fijo, IVA uso común, Ley 18211, etc.) only show in `--format
|
|
593
|
+
json` via the `raw` payload."""
|
|
594
|
+
typer.echo(f"rut: {d.rut}")
|
|
595
|
+
typer.echo(f"period: {d.period}")
|
|
596
|
+
typer.echo(f"side: {d.side}")
|
|
597
|
+
typer.echo(f"código tipo doc: {d.codigo_tipo_doc}")
|
|
598
|
+
typer.echo("")
|
|
599
|
+
if not d.docs:
|
|
600
|
+
typer.echo("(no documents for this period/tipo)")
|
|
601
|
+
return
|
|
602
|
+
typer.echo(
|
|
603
|
+
f"{'folio':>10} {'fecha':<10} {'rut':<14} "
|
|
604
|
+
f"{'razón social':<40} {'neto':>12} {'iva':>12} {'total':>12}"
|
|
605
|
+
)
|
|
606
|
+
typer.echo("-" * 122)
|
|
607
|
+
for row in d.docs:
|
|
608
|
+
typer.echo(
|
|
609
|
+
f"{row.folio if row.folio is not None else '-':>10} "
|
|
610
|
+
f"{(row.fecha_emision or '-'):<10} "
|
|
611
|
+
f"{(row.rut_emisor or '-'):<14} "
|
|
612
|
+
f"{(row.razon_social or '-')[:40]:<40} "
|
|
613
|
+
f"{row.monto_neto if row.monto_neto is not None else '-':>12} "
|
|
614
|
+
f"{row.monto_iva if row.monto_iva is not None else '-':>12} "
|
|
615
|
+
f"{row.monto_total if row.monto_total is not None else '-':>12}"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _parse_rcv_type(value: str) -> RcvSide:
|
|
620
|
+
"""Map the user-facing `--type` spelling to the internal RcvSide enum.
|
|
621
|
+
|
|
622
|
+
The contador-grade surface uses `compras` / `ventas` (ADR-017); the
|
|
623
|
+
older `COMPRA` / `VENTA` spelling of the former `--side` flag stays
|
|
624
|
+
accepted for back-compat. Case-insensitive. The mapping lives in the
|
|
625
|
+
CLI body, NOT in RcvSide — the enum stays the canonical Spanish
|
|
626
|
+
COMPRA/VENTA per the CONVENTIONS three-layer split (surface English,
|
|
627
|
+
internal Spanish).
|
|
628
|
+
"""
|
|
629
|
+
mapping = {
|
|
630
|
+
"compras": RcvSide.COMPRA,
|
|
631
|
+
"compra": RcvSide.COMPRA,
|
|
632
|
+
"ventas": RcvSide.VENTA,
|
|
633
|
+
"venta": RcvSide.VENTA,
|
|
634
|
+
}
|
|
635
|
+
side = mapping.get(value.strip().lower())
|
|
636
|
+
if side is None:
|
|
637
|
+
raise ValueError(
|
|
638
|
+
f"invalid --type {value!r}: expected 'compras' or 'ventas' "
|
|
639
|
+
"(COMPRA/VENTA also accepted)."
|
|
640
|
+
)
|
|
641
|
+
return side
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@rcv_app.command(name="list")
|
|
645
|
+
def rcv_list(
|
|
646
|
+
period: str = typer.Option(
|
|
647
|
+
..., "--period", help="Tax period in YYYY-MM (e.g. 2026-06). Required."
|
|
648
|
+
),
|
|
649
|
+
type_: str = typer.Option(
|
|
650
|
+
...,
|
|
651
|
+
"--type",
|
|
652
|
+
"-t",
|
|
653
|
+
help="compras (DTEs you received) or ventas (DTEs you issued). "
|
|
654
|
+
"COMPRA/VENTA also accepted (case-insensitive).",
|
|
655
|
+
),
|
|
656
|
+
codigo_tipo_doc: str = typer.Option(
|
|
657
|
+
...,
|
|
658
|
+
"--doc-type-code",
|
|
659
|
+
help='SII DTE code (e.g. "33" Factura, "34" Exenta, "39" Boleta, '
|
|
660
|
+
'"61" Nota de Crédito). Use the codes surfaced by `sii rcv summary`.',
|
|
661
|
+
),
|
|
662
|
+
rut: str | None = typer.Option(
|
|
663
|
+
None,
|
|
664
|
+
"--rut",
|
|
665
|
+
help="Operating RUT (e.g. 12345670-K). Defaults to the authenticated "
|
|
666
|
+
"RUT; see ADR-015 for the multi-RUT model.",
|
|
667
|
+
),
|
|
668
|
+
fmt: _OutputFormat = typer.Option(
|
|
669
|
+
_OutputFormat.json,
|
|
670
|
+
"--format",
|
|
671
|
+
"-f",
|
|
672
|
+
case_sensitive=False,
|
|
673
|
+
help="Output format. JSON (default) includes the full `raw` payload "
|
|
674
|
+
"per row for tax-special cases; table shows the curated set.",
|
|
675
|
+
),
|
|
676
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
677
|
+
) -> None:
|
|
678
|
+
"""Per-DTE rows for one (period, --type, --doc-type-code).
|
|
679
|
+
|
|
680
|
+
Returns individual folios with counterparty RUT / razón social /
|
|
681
|
+
fechas / montos. PII surface: counterparty RUTs and razón social
|
|
682
|
+
are NOT redacted by default — pipe through jq or use `--format
|
|
683
|
+
table` if you need to filter.
|
|
684
|
+
"""
|
|
685
|
+
if verbose:
|
|
686
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
687
|
+
try:
|
|
688
|
+
side = _parse_rcv_type(type_)
|
|
689
|
+
except ValueError as exc:
|
|
690
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
691
|
+
raise typer.Exit(code=2)
|
|
692
|
+
try:
|
|
693
|
+
d = asyncio.run(
|
|
694
|
+
consultar_rcv_detalle(
|
|
695
|
+
period=period,
|
|
696
|
+
side=side,
|
|
697
|
+
codigo_tipo_doc=codigo_tipo_doc,
|
|
698
|
+
rut_operativo=rut,
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
702
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
703
|
+
raise typer.Exit(code=2)
|
|
704
|
+
except LoginFailedError as exc:
|
|
705
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
706
|
+
raise typer.Exit(code=3)
|
|
707
|
+
except RateLimitError as exc:
|
|
708
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
709
|
+
raise typer.Exit(code=4)
|
|
710
|
+
except ValueError as exc:
|
|
711
|
+
typer.secho(f"invalid argument: {exc}", fg=typer.colors.RED, err=True)
|
|
712
|
+
raise typer.Exit(code=2)
|
|
713
|
+
if fmt is _OutputFormat.json:
|
|
714
|
+
_print_rcv_detalle_json(d)
|
|
715
|
+
else:
|
|
716
|
+
_print_rcv_detalle_table(d)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _print_rcv_match_json(m: RcvMatchResult) -> None:
|
|
720
|
+
typer.echo(json.dumps(asdict(m), indent=2, ensure_ascii=False))
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _print_rcv_match_table(m: RcvMatchResult) -> None:
|
|
724
|
+
"""Curated human view: the headline `found`, then either the matched
|
|
725
|
+
row or an honest 'not found in <range>'. Full payload (incl. the
|
|
726
|
+
`raw` block of the matched row) is in `--format json`."""
|
|
727
|
+
typer.echo(f"folio: {m.folio}")
|
|
728
|
+
typer.echo(f"rut: {m.rut}")
|
|
729
|
+
if m.rut_emisor_filter:
|
|
730
|
+
typer.echo(f"rut emisor filter: {m.rut_emisor_filter}")
|
|
731
|
+
typer.echo(f"found: {str(m.found).lower()}")
|
|
732
|
+
if m.found and m.row is not None:
|
|
733
|
+
r = m.row
|
|
734
|
+
typer.echo(f"period: {m.period}")
|
|
735
|
+
typer.echo(f"side: {m.side}")
|
|
736
|
+
typer.echo(f"código tipo doc: {m.codigo_tipo_doc}")
|
|
737
|
+
typer.echo(f"rut emisor: {r.rut_emisor or '-'}")
|
|
738
|
+
typer.echo(f"razón social: {r.razon_social or '-'}")
|
|
739
|
+
typer.echo(f"fecha emisión: {r.fecha_emision or '-'}")
|
|
740
|
+
typer.echo(f"monto total: {r.monto_total if r.monto_total is not None else '-'}")
|
|
741
|
+
elif m.periods_searched:
|
|
742
|
+
typer.echo(
|
|
743
|
+
f"not found in {m.periods_searched[0]} → {m.periods_searched[-1]} "
|
|
744
|
+
f"({len(m.periods_searched)} months searched)"
|
|
745
|
+
)
|
|
746
|
+
else:
|
|
747
|
+
typer.echo("not found (no periods searched)")
|
|
748
|
+
if m.incomplete and not m.found:
|
|
749
|
+
typer.secho(
|
|
750
|
+
"warning: some RCV reads were rejected by SII — this negative is not "
|
|
751
|
+
"definitive (incomplete=true).",
|
|
752
|
+
fg=typer.colors.YELLOW,
|
|
753
|
+
err=True,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@rcv_app.command(name="match")
|
|
758
|
+
def rcv_match_cmd(
|
|
759
|
+
dte: int = typer.Option(
|
|
760
|
+
..., "--dte", "--folio", help="DTE folio number to reconcile against the RCV."
|
|
761
|
+
),
|
|
762
|
+
rut_emisor: str | None = typer.Option(
|
|
763
|
+
None,
|
|
764
|
+
"--rut-emisor",
|
|
765
|
+
help="Counterparty RUT (e.g. 12345670-K) to disambiguate when the same "
|
|
766
|
+
"folio number exists across different emisores. Filtered client-side.",
|
|
767
|
+
),
|
|
768
|
+
range_from: str | None = typer.Option(
|
|
769
|
+
None,
|
|
770
|
+
"--from",
|
|
771
|
+
help="Search window start (inclusive) in YYYY-MM. Requires --to. "
|
|
772
|
+
"Defaults to the last 12 months.",
|
|
773
|
+
),
|
|
774
|
+
range_to: str | None = typer.Option(
|
|
775
|
+
None,
|
|
776
|
+
"--to",
|
|
777
|
+
help="Search window end (inclusive) in YYYY-MM. Requires --from.",
|
|
778
|
+
),
|
|
779
|
+
rut: str | None = typer.Option(
|
|
780
|
+
None,
|
|
781
|
+
"--rut",
|
|
782
|
+
help="Operating RUT (e.g. 12345670-K). Defaults to the authenticated "
|
|
783
|
+
"RUT; see ADR-015 for the multi-RUT model.",
|
|
784
|
+
),
|
|
785
|
+
fmt: _OutputFormat = typer.Option(
|
|
786
|
+
_OutputFormat.json,
|
|
787
|
+
"--format",
|
|
788
|
+
"-f",
|
|
789
|
+
case_sensitive=False,
|
|
790
|
+
help="Output format. JSON (default) for machine consumption / piping to jq; "
|
|
791
|
+
"table for a curated human readout.",
|
|
792
|
+
),
|
|
793
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Reconcile a DTE folio against the RCV — does SII have it, and where?
|
|
796
|
+
|
|
797
|
+
Searches RECEIVED DTEs (COMPRA side) for the folio across the period
|
|
798
|
+
window (default: the last 12 months), reusing ONE session paced per
|
|
799
|
+
ADR-011. Prints the matched row, or an honest "not found in <range>"
|
|
800
|
+
naming exactly the months searched. Use `--rut-emisor` to
|
|
801
|
+
disambiguate a folio that recurs across counterparties.
|
|
802
|
+
"""
|
|
803
|
+
if verbose:
|
|
804
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
805
|
+
|
|
806
|
+
# --from / --to must come together; default window (last 12 months)
|
|
807
|
+
# is computed in the task when neither is supplied.
|
|
808
|
+
if (range_from is None) ^ (range_to is None):
|
|
809
|
+
typer.secho(
|
|
810
|
+
"invalid arguments: --from and --to must be supplied together.",
|
|
811
|
+
fg=typer.colors.RED,
|
|
812
|
+
err=True,
|
|
813
|
+
)
|
|
814
|
+
raise typer.Exit(code=2)
|
|
815
|
+
window: tuple[str, str] | None = None
|
|
816
|
+
if range_from is not None and range_to is not None:
|
|
817
|
+
window = (range_from, range_to)
|
|
818
|
+
|
|
819
|
+
try:
|
|
820
|
+
m = asyncio.run(
|
|
821
|
+
consultar_rcv_match(
|
|
822
|
+
folio=dte,
|
|
823
|
+
rut_emisor=rut_emisor,
|
|
824
|
+
period_window=window,
|
|
825
|
+
rut_operativo=rut,
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
829
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
830
|
+
raise typer.Exit(code=2)
|
|
831
|
+
except LoginFailedError as exc:
|
|
832
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
833
|
+
raise typer.Exit(code=3)
|
|
834
|
+
except RateLimitError as exc:
|
|
835
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
836
|
+
raise typer.Exit(code=4)
|
|
837
|
+
except ValueError as exc:
|
|
838
|
+
typer.secho(f"invalid argument: {exc}", fg=typer.colors.RED, err=True)
|
|
839
|
+
raise typer.Exit(code=2)
|
|
840
|
+
if fmt is _OutputFormat.json:
|
|
841
|
+
_print_rcv_match_json(m)
|
|
842
|
+
else:
|
|
843
|
+
_print_rcv_match_table(m)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _print_f29_propuesta_json(p: F29Propuesta) -> None:
|
|
847
|
+
typer.echo(json.dumps(asdict(p), indent=2, ensure_ascii=False))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _print_f29_propuesta_table(p: F29Propuesta) -> None:
|
|
851
|
+
"""Curated human view: header + the proposed código grid. The full
|
|
852
|
+
payload (incl. the listCodBase header PII) only shows in `--format json`
|
|
853
|
+
via the `raw` field."""
|
|
854
|
+
typer.echo(f"rut: {p.rut}")
|
|
855
|
+
typer.echo(f"period: {p.period}")
|
|
856
|
+
typer.echo(f"estado: {p.estado if p.estado is not None else '-'}")
|
|
857
|
+
if p.descripcion_estado:
|
|
858
|
+
typer.echo(f"descripción: {p.descripcion_estado}")
|
|
859
|
+
typer.echo(f"tipo propuesta: {p.tipo_propuesta if p.tipo_propuesta is not None else '-'}")
|
|
860
|
+
typer.echo("")
|
|
861
|
+
if not p.codigos:
|
|
862
|
+
typer.echo("(no proposed códigos for this period)")
|
|
863
|
+
return
|
|
864
|
+
typer.echo(f"{'código':<8} {'valor':>14} {'glosa':<50}")
|
|
865
|
+
typer.echo("-" * 76)
|
|
866
|
+
for c in p.codigos:
|
|
867
|
+
typer.echo(
|
|
868
|
+
f"{c.codigo:<8} "
|
|
869
|
+
f"{c.valor if c.valor is not None else '-':>14} "
|
|
870
|
+
f"{(c.glosa or '-')[:50]:<50}"
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
@f29_app.command()
|
|
875
|
+
def draft(
|
|
876
|
+
period: str = typer.Option(
|
|
877
|
+
..., "--period", help="Tax period in YYYY-MM (e.g. 2026-05). Required."
|
|
878
|
+
),
|
|
879
|
+
rut: str | None = typer.Option(
|
|
880
|
+
None,
|
|
881
|
+
"--rut",
|
|
882
|
+
help="Operating RUT (e.g. 12345670-K). Defaults to the authenticated "
|
|
883
|
+
"RUT; see ADR-015 for the multi-RUT model.",
|
|
884
|
+
),
|
|
885
|
+
fmt: _OutputFormat = typer.Option(
|
|
886
|
+
_OutputFormat.json,
|
|
887
|
+
"--format",
|
|
888
|
+
"-f",
|
|
889
|
+
case_sensitive=False,
|
|
890
|
+
help="Output format. JSON (default) includes the full `raw` payload "
|
|
891
|
+
"for tax-special cases; table shows the curated código grid.",
|
|
892
|
+
),
|
|
893
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
894
|
+
) -> None:
|
|
895
|
+
"""Read SII's pre-filled F29 (IVA mensual) propuesta for a period.
|
|
896
|
+
|
|
897
|
+
Read-only — never submits. Surfaces YOUR OWN tax position: the curated
|
|
898
|
+
output is the proposed código grid; the full payload (including the
|
|
899
|
+
header PII block) is in `raw` under `--format json`. Pipe through jq to
|
|
900
|
+
mask. Use `sii auth login` first if not authenticated.
|
|
901
|
+
"""
|
|
902
|
+
if verbose:
|
|
903
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
904
|
+
try:
|
|
905
|
+
p = asyncio.run(consultar_f29_propuesta(period=period, rut_operativo=rut))
|
|
906
|
+
except (CredentialNotFoundError, NotAuthenticatedError) as exc:
|
|
907
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
908
|
+
raise typer.Exit(code=2)
|
|
909
|
+
except LoginFailedError as exc:
|
|
910
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
911
|
+
raise typer.Exit(code=3)
|
|
912
|
+
except RateLimitError as exc:
|
|
913
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
|
|
914
|
+
raise typer.Exit(code=4)
|
|
915
|
+
except ValueError as exc:
|
|
916
|
+
typer.secho(f"invalid argument: {exc}", fg=typer.colors.RED, err=True)
|
|
917
|
+
raise typer.Exit(code=2)
|
|
918
|
+
if fmt is _OutputFormat.json:
|
|
919
|
+
_print_f29_propuesta_json(p)
|
|
920
|
+
else:
|
|
921
|
+
_print_f29_propuesta_table(p)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def main() -> None:
|
|
925
|
+
app()
|