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/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()