impreza-cli 0.3.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.
- impreza_cli/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""``impreza account`` subcommand surface — Phase 2.2.
|
|
2
|
+
|
|
3
|
+
Three verbs reading from `c.account.*`:
|
|
4
|
+
|
|
5
|
+
* ``impreza account info``
|
|
6
|
+
Authenticated client's profile + balance, rendered as a
|
|
7
|
+
field/value table (or JSON / YAML).
|
|
8
|
+
|
|
9
|
+
* ``impreza account balance``
|
|
10
|
+
Just the numeric balance + currency. Terse, scriptable —
|
|
11
|
+
designed to drop into shell pipelines (``$(impreza account balance
|
|
12
|
+
--raw)`` returns a single number).
|
|
13
|
+
|
|
14
|
+
* ``impreza account services [--status STATUS]``
|
|
15
|
+
Active / pending / cancelled / etc. services on the account,
|
|
16
|
+
one row per service with id / domain / product / status / next-due
|
|
17
|
+
columns. Optional ``--status`` filter passes through to the SDK.
|
|
18
|
+
|
|
19
|
+
All three commands route through :func:`impreza_cli.sdk.make_client_or_exit`,
|
|
20
|
+
so context resolution failures (no contexts configured, missing
|
|
21
|
+
default, unknown ``--context`` override) surface as friendly stderr
|
|
22
|
+
errors with a non-zero exit code rather than tracebacks.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import webbrowser
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import typer
|
|
34
|
+
from impreza import TopupInvoice
|
|
35
|
+
from impreza.exceptions import ApiError
|
|
36
|
+
|
|
37
|
+
from ..output import OutputFormat, error, print_dict, print_table, success
|
|
38
|
+
from ..output import info as info_msg # `info` is also the verb name @app.command("info")
|
|
39
|
+
from ..sdk import make_client_or_exit
|
|
40
|
+
from ..state import from_typer_context, resolve_output
|
|
41
|
+
from ._helpers import exit_on_api_error as _exit_on_api_error
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
name="account",
|
|
45
|
+
help="Read your account profile, balance, and active services.",
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── account info ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("info")
|
|
54
|
+
def info(
|
|
55
|
+
typer_ctx: typer.Context,
|
|
56
|
+
output: OutputFormat | None = typer.Option(
|
|
57
|
+
None,
|
|
58
|
+
"--output",
|
|
59
|
+
"-o",
|
|
60
|
+
help="Output format. Overrides the global --output flag.",
|
|
61
|
+
case_sensitive=False,
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Show your client profile and balance.
|
|
65
|
+
|
|
66
|
+
Wraps ``GET /account``. The fields rendered are:
|
|
67
|
+
name (first/last + optional company), email, balance + currency,
|
|
68
|
+
and the date the account was registered.
|
|
69
|
+
"""
|
|
70
|
+
state = from_typer_context(typer_ctx)
|
|
71
|
+
fmt = resolve_output(state, output)
|
|
72
|
+
|
|
73
|
+
with make_client_or_exit(state) as client:
|
|
74
|
+
try:
|
|
75
|
+
me = client.account.get()
|
|
76
|
+
except ApiError as exc:
|
|
77
|
+
_exit_on_api_error(exc)
|
|
78
|
+
|
|
79
|
+
full_name = f"{me.first_name} {me.last_name}".strip()
|
|
80
|
+
if me.company:
|
|
81
|
+
full_name = f"{full_name} ({me.company})"
|
|
82
|
+
|
|
83
|
+
data: dict[str, Any] = {
|
|
84
|
+
"id": me.id,
|
|
85
|
+
"name": full_name,
|
|
86
|
+
"email": me.email,
|
|
87
|
+
"balance": f"{me.balance:.2f} {me.currency}"
|
|
88
|
+
if fmt is OutputFormat.TABLE
|
|
89
|
+
else me.balance,
|
|
90
|
+
"currency": me.currency,
|
|
91
|
+
"registered_at": me.registered_at,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
print_dict("Account", data, fmt=fmt)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── account balance ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("balance")
|
|
101
|
+
def balance(
|
|
102
|
+
typer_ctx: typer.Context,
|
|
103
|
+
raw: bool = typer.Option(
|
|
104
|
+
False,
|
|
105
|
+
"--raw",
|
|
106
|
+
help=(
|
|
107
|
+
"Print just the numeric balance with no currency or formatting "
|
|
108
|
+
"— useful in shell substitutions like `$(impreza account "
|
|
109
|
+
"balance --raw)`."
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Print the current account balance.
|
|
114
|
+
|
|
115
|
+
Default output: ``45.32 USD`` on a single line, suitable for
|
|
116
|
+
quick eyeballing. ``--raw`` strips the currency for shell
|
|
117
|
+
arithmetic.
|
|
118
|
+
"""
|
|
119
|
+
state = from_typer_context(typer_ctx)
|
|
120
|
+
with make_client_or_exit(state) as client:
|
|
121
|
+
try:
|
|
122
|
+
me = client.account.get()
|
|
123
|
+
except ApiError as exc:
|
|
124
|
+
_exit_on_api_error(exc)
|
|
125
|
+
|
|
126
|
+
if raw:
|
|
127
|
+
# Single line, no trailing currency. Stays parseable by bc /
|
|
128
|
+
# python -c / awk without futzing with split.
|
|
129
|
+
typer.echo(f"{me.balance:.2f}")
|
|
130
|
+
else:
|
|
131
|
+
typer.echo(f"{me.balance:.2f} {me.currency}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── account services ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_SERVICE_COLUMNS = [
|
|
138
|
+
"id",
|
|
139
|
+
"domain",
|
|
140
|
+
"product",
|
|
141
|
+
"status",
|
|
142
|
+
"billing_cycle",
|
|
143
|
+
"amount",
|
|
144
|
+
"next_due",
|
|
145
|
+
"vps_backend",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("services")
|
|
150
|
+
def services(
|
|
151
|
+
typer_ctx: typer.Context,
|
|
152
|
+
status: str | None = typer.Option(
|
|
153
|
+
None,
|
|
154
|
+
"--status",
|
|
155
|
+
help=(
|
|
156
|
+
"Filter by service status (Active, Pending, Suspended, "
|
|
157
|
+
"Cancelled, Terminated, Fraud). Case-insensitive at the API "
|
|
158
|
+
"layer; passed through verbatim."
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
output: OutputFormat | None = typer.Option(
|
|
162
|
+
None,
|
|
163
|
+
"--output",
|
|
164
|
+
"-o",
|
|
165
|
+
help="Output format. Overrides the global --output flag.",
|
|
166
|
+
case_sensitive=False,
|
|
167
|
+
),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""List the account's services across all backends.
|
|
170
|
+
|
|
171
|
+
Wraps ``GET /account/services``. Each row carries the
|
|
172
|
+
service id (use this with ``impreza vps show <id>`` etc.),
|
|
173
|
+
domain, product name, status, billing cycle, recurring amount,
|
|
174
|
+
next-due date, and the resolved ``vps_backend`` discriminator
|
|
175
|
+
(``proxmox``, ``cloud``, or empty for non-VPS services).
|
|
176
|
+
"""
|
|
177
|
+
state = from_typer_context(typer_ctx)
|
|
178
|
+
fmt = resolve_output(state, output)
|
|
179
|
+
|
|
180
|
+
with make_client_or_exit(state) as client:
|
|
181
|
+
try:
|
|
182
|
+
items = client.account.services.list(status=status)
|
|
183
|
+
except ApiError as exc:
|
|
184
|
+
_exit_on_api_error(exc)
|
|
185
|
+
|
|
186
|
+
rows: list[dict[str, Any]] = []
|
|
187
|
+
for svc in items:
|
|
188
|
+
rows.append(
|
|
189
|
+
{
|
|
190
|
+
"id": svc.id,
|
|
191
|
+
"domain": svc.domain,
|
|
192
|
+
"product": svc.product,
|
|
193
|
+
"status": svc.status,
|
|
194
|
+
"billing_cycle": svc.billing_cycle,
|
|
195
|
+
"amount": f"{svc.amount:.2f}"
|
|
196
|
+
if fmt is OutputFormat.TABLE
|
|
197
|
+
else svc.amount,
|
|
198
|
+
"next_due": svc.next_due,
|
|
199
|
+
"vps_backend": svc.vps_backend,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not rows:
|
|
204
|
+
msg = (
|
|
205
|
+
f"No services with status {status!r} on this account."
|
|
206
|
+
if status
|
|
207
|
+
else "No services on this account yet."
|
|
208
|
+
)
|
|
209
|
+
typer.echo(msg)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
title = (
|
|
213
|
+
f"Services ({status})"
|
|
214
|
+
if status
|
|
215
|
+
else f"Services ({len(rows)} total)"
|
|
216
|
+
)
|
|
217
|
+
print_table(title, rows, columns=_SERVICE_COLUMNS, fmt=fmt)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── account topup (Phase 3.6, polished in 4.2) ──────────────────────
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Crypto confirmations are slow; matches the SDK's default in _topup.py.
|
|
224
|
+
_TOPUP_POLL_INTERVAL_SECONDS = 30.0
|
|
225
|
+
|
|
226
|
+
# Width to pad the in-place progress line to, so each redraw fully
|
|
227
|
+
# overwrites the previous one regardless of terminal width. 100 chars
|
|
228
|
+
# is wider than the longest line the formatter produces and short
|
|
229
|
+
# enough to fit standard terminal widths without wrapping.
|
|
230
|
+
_PROGRESS_PAD_WIDTH = 100
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _seconds_until_expiry(expires_at: str | None) -> float | None:
|
|
234
|
+
"""Parse an ISO 8601 timestamp and return seconds from now until
|
|
235
|
+
that moment, in UTC. Returns ``None`` if the input is missing or
|
|
236
|
+
unparseable — the caller decides whether to suppress the
|
|
237
|
+
"until expiry" portion of the progress line in that case.
|
|
238
|
+
|
|
239
|
+
Server emits the timestamp as ``"2026-05-11T20:00:00Z"`` (RFC
|
|
240
|
+
3339-ish with Z suffix). Python's ``datetime.fromisoformat`` was
|
|
241
|
+
extended to accept ``Z`` only in 3.11+; we still target 3.10+ so
|
|
242
|
+
swap ``Z`` for ``+00:00`` before parsing for portability.
|
|
243
|
+
"""
|
|
244
|
+
if not expires_at:
|
|
245
|
+
return None
|
|
246
|
+
iso = expires_at
|
|
247
|
+
if iso.endswith("Z"):
|
|
248
|
+
iso = iso[:-1] + "+00:00"
|
|
249
|
+
try:
|
|
250
|
+
target = datetime.fromisoformat(iso)
|
|
251
|
+
except ValueError:
|
|
252
|
+
return None
|
|
253
|
+
if target.tzinfo is None:
|
|
254
|
+
# Treat naive timestamps as UTC — matches what the server
|
|
255
|
+
# actually emits even when the Z gets stripped by an
|
|
256
|
+
# intermediate proxy.
|
|
257
|
+
target = target.replace(tzinfo=timezone.utc)
|
|
258
|
+
return (target - datetime.now(timezone.utc)).total_seconds()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _format_progress_line(invoice: TopupInvoice, elapsed_s: float) -> str:
|
|
262
|
+
"""Render the single-line in-place progress message:
|
|
263
|
+
``Waiting on top-up invoice N — Xs elapsed / Ys until expiry``.
|
|
264
|
+
|
|
265
|
+
The "until expiry" portion is appended only when ``expires_at``
|
|
266
|
+
parses to a future timestamp. Past-expiry case omits it (the
|
|
267
|
+
next poll iteration will hit the ``--timeout`` branch and
|
|
268
|
+
surface the failure with the payment URL).
|
|
269
|
+
"""
|
|
270
|
+
line = (
|
|
271
|
+
f"Waiting on top-up invoice {invoice.invoice_id} "
|
|
272
|
+
f"— {elapsed_s:.0f}s elapsed"
|
|
273
|
+
)
|
|
274
|
+
remaining = _seconds_until_expiry(invoice.expires_at)
|
|
275
|
+
if remaining is not None and remaining > 0:
|
|
276
|
+
line += f" / {remaining:.0f}s until expiry"
|
|
277
|
+
return line
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _write_progress(line: str) -> None:
|
|
281
|
+
"""Write a carriage-return-redrawn progress line to stdout,
|
|
282
|
+
padded so previous-iteration leftover characters are
|
|
283
|
+
overwritten. ``flush()`` ensures the line appears immediately
|
|
284
|
+
even when stdout is line-buffered.
|
|
285
|
+
|
|
286
|
+
Test capture via ``CliRunner`` records every byte written so
|
|
287
|
+
assertions can still find the latest line text in the
|
|
288
|
+
accumulated output buffer.
|
|
289
|
+
"""
|
|
290
|
+
sys.stdout.write("\r" + line.ljust(_PROGRESS_PAD_WIDTH))
|
|
291
|
+
sys.stdout.flush()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _clear_progress() -> None:
|
|
295
|
+
"""Erase the in-place progress line so the next ``typer.echo``
|
|
296
|
+
starts on a clean row."""
|
|
297
|
+
sys.stdout.write("\r" + " " * _PROGRESS_PAD_WIDTH + "\r")
|
|
298
|
+
sys.stdout.flush()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _wait_for_topup(
|
|
302
|
+
invoice: TopupInvoice,
|
|
303
|
+
*,
|
|
304
|
+
timeout: int,
|
|
305
|
+
poll_interval: float = _TOPUP_POLL_INTERVAL_SECONDS,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Block on a :class:`TopupInvoice` future, redrawing a single
|
|
308
|
+
progress line each poll cycle so the user sees elapsed + ETA
|
|
309
|
+
until invoice expiry without scrolling the terminal.
|
|
310
|
+
|
|
311
|
+
Mirrors :func:`commands._helpers.wait_for_operation` for
|
|
312
|
+
the TopupInvoice future. Crypto poll intervals default to 30s
|
|
313
|
+
in the SDK — keep that here so the CLI doesn't hammer the
|
|
314
|
+
gateway faster than the SDK would in programmatic use.
|
|
315
|
+
|
|
316
|
+
The renderer uses bare ``sys.stdout`` (not ``typer.echo``)
|
|
317
|
+
because the in-place ``\\r`` rewrite needs to skip Click's
|
|
318
|
+
line buffering. Final state always lands as a clean settled
|
|
319
|
+
line so the next renderer call (the post-wait
|
|
320
|
+
``_render_topup_invoice``) starts on a fresh row.
|
|
321
|
+
"""
|
|
322
|
+
elapsed = 0.0
|
|
323
|
+
while not invoice.is_done():
|
|
324
|
+
if elapsed >= timeout:
|
|
325
|
+
_clear_progress()
|
|
326
|
+
error(
|
|
327
|
+
f"Top-up invoice {invoice.invoice_id} not paid within "
|
|
328
|
+
f"{timeout}s (last status: {invoice.status!r}). "
|
|
329
|
+
"Re-run with a larger --timeout, or check the payment "
|
|
330
|
+
f"URL: {invoice.payment_url or '(unknown)'}"
|
|
331
|
+
)
|
|
332
|
+
raise typer.Exit(code=1)
|
|
333
|
+
_write_progress(_format_progress_line(invoice, elapsed))
|
|
334
|
+
time.sleep(poll_interval)
|
|
335
|
+
elapsed += poll_interval
|
|
336
|
+
try:
|
|
337
|
+
invoice.refresh()
|
|
338
|
+
except ApiError as exc:
|
|
339
|
+
_clear_progress()
|
|
340
|
+
_exit_on_api_error(exc)
|
|
341
|
+
_clear_progress()
|
|
342
|
+
# Status-conditional rendering: paid → success (green); failed
|
|
343
|
+
# gets the error() line below + we keep this neutral via info_msg().
|
|
344
|
+
if invoice.is_paid():
|
|
345
|
+
success(
|
|
346
|
+
f"Top-up invoice {invoice.invoice_id} settled: "
|
|
347
|
+
f"status={invoice.status!r}"
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
info_msg(
|
|
351
|
+
f"Top-up invoice {invoice.invoice_id} settled: "
|
|
352
|
+
f"status={invoice.status!r}"
|
|
353
|
+
)
|
|
354
|
+
if invoice.is_failed():
|
|
355
|
+
error(
|
|
356
|
+
f"Top-up invoice {invoice.invoice_id} ended in "
|
|
357
|
+
f"{invoice.status!r}. Funds were not credited."
|
|
358
|
+
)
|
|
359
|
+
raise typer.Exit(code=1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _open_payment_url(invoice: TopupInvoice) -> None:
|
|
363
|
+
"""Best-effort: open the invoice's ``payment_url`` in the
|
|
364
|
+
default browser, or print a friendly message if the URL is
|
|
365
|
+
missing or the OS doesn't have a browser configured.
|
|
366
|
+
|
|
367
|
+
Never raises. ``webbrowser.open()`` returns False when no
|
|
368
|
+
browser was found, True when the OS spawned one — but on some
|
|
369
|
+
headless Linux setups it raises instead, hence the broad
|
|
370
|
+
except. Either way the user still has the URL printed by
|
|
371
|
+
:func:`_render_topup_invoice` and can copy/paste it.
|
|
372
|
+
"""
|
|
373
|
+
if not invoice.payment_url:
|
|
374
|
+
info_msg(
|
|
375
|
+
" (no payment_url returned — invoice may already be "
|
|
376
|
+
"settled or the gateway is misconfigured)"
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
try:
|
|
380
|
+
opened = webbrowser.open(invoice.payment_url)
|
|
381
|
+
except Exception as exc: # noqa: BLE001 — webbrowser raises bare Exception on headless
|
|
382
|
+
info_msg(
|
|
383
|
+
f" (could not open browser: {exc}; copy the URL above to pay)"
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
if opened:
|
|
387
|
+
info_msg(" (payment URL opened in your default browser)")
|
|
388
|
+
else:
|
|
389
|
+
info_msg(
|
|
390
|
+
" (no default browser configured; copy the URL above to pay)"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _render_topup_invoice(
|
|
395
|
+
invoice: TopupInvoice,
|
|
396
|
+
*,
|
|
397
|
+
fmt: OutputFormat,
|
|
398
|
+
title: str,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Common renderer for ``topup`` and ``topup-status``. Picks
|
|
401
|
+
fields that are useful in both states (just-created vs polled)."""
|
|
402
|
+
data: dict[str, Any] = {
|
|
403
|
+
"invoice_id": invoice.invoice_id,
|
|
404
|
+
"amount": (
|
|
405
|
+
f"{invoice.amount:.2f}"
|
|
406
|
+
if fmt is OutputFormat.TABLE
|
|
407
|
+
else invoice.amount
|
|
408
|
+
),
|
|
409
|
+
"currency": invoice.currency,
|
|
410
|
+
"method": invoice.method or "",
|
|
411
|
+
"status": invoice.status,
|
|
412
|
+
"payment_url": invoice.payment_url or "",
|
|
413
|
+
"expires_at": invoice.expires_at or "",
|
|
414
|
+
"paid_at": invoice.paid_at or "",
|
|
415
|
+
"balance_after": (
|
|
416
|
+
f"{invoice.balance_after:.2f}"
|
|
417
|
+
if invoice.balance_after is not None and fmt is OutputFormat.TABLE
|
|
418
|
+
else invoice.balance_after
|
|
419
|
+
),
|
|
420
|
+
}
|
|
421
|
+
print_dict(title, data, fmt=fmt)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@app.command("topup")
|
|
425
|
+
def topup(
|
|
426
|
+
typer_ctx: typer.Context,
|
|
427
|
+
amount: float = typer.Option(
|
|
428
|
+
...,
|
|
429
|
+
"--amount", "-a",
|
|
430
|
+
help=(
|
|
431
|
+
"Amount to top up, in account currency (USD by default). "
|
|
432
|
+
"The crypto-gateway converts to the chosen --method at the "
|
|
433
|
+
"spot rate when the invoice is created."
|
|
434
|
+
),
|
|
435
|
+
min=0.0,
|
|
436
|
+
),
|
|
437
|
+
method: str | None = typer.Option(
|
|
438
|
+
None,
|
|
439
|
+
"--method", "-m",
|
|
440
|
+
help=(
|
|
441
|
+
"Optional crypto method hint (e.g. 'btc', 'xmr', "
|
|
442
|
+
"'usdt-trc20'). Defaults to the gateway's default if "
|
|
443
|
+
"omitted — the payment URL lets the customer pick at the "
|
|
444
|
+
"BTCPay step either way."
|
|
445
|
+
),
|
|
446
|
+
),
|
|
447
|
+
browser: bool = typer.Option(
|
|
448
|
+
False,
|
|
449
|
+
"--browser",
|
|
450
|
+
help=(
|
|
451
|
+
"Open the invoice's payment_url in the system browser "
|
|
452
|
+
"immediately after the create call. Opt-in — the default "
|
|
453
|
+
"flow stays scriptable. No-op when --output json is set "
|
|
454
|
+
"since the JSON consumer typically handles the URL itself."
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
wait: bool = typer.Option(
|
|
458
|
+
False,
|
|
459
|
+
"--wait",
|
|
460
|
+
help=(
|
|
461
|
+
"Block until the gateway confirms payment (or the invoice "
|
|
462
|
+
"expires). Crypto confirmations are slow — default timeout "
|
|
463
|
+
"matches the server-side invoice expiry of 2h. Progress "
|
|
464
|
+
"renders in place with elapsed time + ETA until expiry."
|
|
465
|
+
),
|
|
466
|
+
),
|
|
467
|
+
timeout: int = typer.Option(
|
|
468
|
+
7200,
|
|
469
|
+
"--timeout",
|
|
470
|
+
help=(
|
|
471
|
+
"Max seconds to wait when --wait is set. Default 7200 (2h) "
|
|
472
|
+
"matches the server-side invoice expiry."
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
output: OutputFormat | None = typer.Option(
|
|
476
|
+
None, "--output", "-o",
|
|
477
|
+
help="Output format. Overrides the global --output flag.",
|
|
478
|
+
case_sensitive=False,
|
|
479
|
+
),
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Create a crypto top-up invoice.
|
|
482
|
+
|
|
483
|
+
Wraps ``c.account.topup(amount=..., method=...)``. The server
|
|
484
|
+
creates an ``AddFunds`` invoice routed to the ``btcpayinline``
|
|
485
|
+
gateway and returns a :class:`TopupInvoice` future with a
|
|
486
|
+
``payment_url``. Open the URL in a browser to pay; once the
|
|
487
|
+
gateway confirms, your Impreza Account credits the balance automatically.
|
|
488
|
+
|
|
489
|
+
Use ``--browser`` to skip the copy-paste step and open the
|
|
490
|
+
payment URL automatically. Use ``--wait`` to block until paid
|
|
491
|
+
(or until the 2h invoice expires); the progress renderer shows
|
|
492
|
+
elapsed time and an ETA until expiry, redrawn in place.
|
|
493
|
+
Without ``--wait`` the CLI prints the invoice details and
|
|
494
|
+
exits — poll later with
|
|
495
|
+
``impreza account topup-status <invoice-id>``.
|
|
496
|
+
"""
|
|
497
|
+
state = from_typer_context(typer_ctx)
|
|
498
|
+
fmt = resolve_output(state, output)
|
|
499
|
+
|
|
500
|
+
with make_client_or_exit(state) as client:
|
|
501
|
+
try:
|
|
502
|
+
invoice = client.account.topup(amount=amount, method=method)
|
|
503
|
+
except ApiError as exc:
|
|
504
|
+
_exit_on_api_error(exc)
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# Render the freshly-created invoice (payment URL is critical here).
|
|
508
|
+
_render_topup_invoice(
|
|
509
|
+
invoice,
|
|
510
|
+
fmt=fmt,
|
|
511
|
+
title=f"Top-up invoice {invoice.invoice_id} (just created)",
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# --browser kicks the OS browser at the payment_url. Suppressed
|
|
515
|
+
# in JSON mode because the JSON consumer is a script that
|
|
516
|
+
# presumably handles the URL itself; opening a browser would
|
|
517
|
+
# be a surprise side effect.
|
|
518
|
+
if browser and fmt is OutputFormat.TABLE:
|
|
519
|
+
_open_payment_url(invoice)
|
|
520
|
+
|
|
521
|
+
if not wait:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
if invoice.is_done():
|
|
525
|
+
# Edge case: gateway confirmed before we got here. Render
|
|
526
|
+
# the final state and exit.
|
|
527
|
+
typer.echo(
|
|
528
|
+
f"Top-up invoice {invoice.invoice_id} is already "
|
|
529
|
+
f"{invoice.status!r}."
|
|
530
|
+
)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
_wait_for_topup(invoice, timeout=timeout)
|
|
534
|
+
# After wait, render the settled state.
|
|
535
|
+
_render_topup_invoice(
|
|
536
|
+
invoice,
|
|
537
|
+
fmt=fmt,
|
|
538
|
+
title=f"Top-up invoice {invoice.invoice_id} (settled)",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@app.command("topup-status")
|
|
543
|
+
def topup_status(
|
|
544
|
+
typer_ctx: typer.Context,
|
|
545
|
+
invoice_id: int = typer.Argument(
|
|
546
|
+
...,
|
|
547
|
+
help="Invoice id returned by `impreza account topup`.",
|
|
548
|
+
),
|
|
549
|
+
output: OutputFormat | None = typer.Option(
|
|
550
|
+
None, "--output", "-o",
|
|
551
|
+
help="Output format. Overrides the global --output flag.",
|
|
552
|
+
case_sensitive=False,
|
|
553
|
+
),
|
|
554
|
+
) -> None:
|
|
555
|
+
"""Check the current status of a top-up invoice.
|
|
556
|
+
|
|
557
|
+
Wraps ``c.account.topup_status(invoice_id)``. Returns the latest
|
|
558
|
+
gateway state without blocking. Note that ``payment_url`` and
|
|
559
|
+
``expires_at`` are not echoed by this endpoint (they're set
|
|
560
|
+
once on creation) — they'll appear empty here.
|
|
561
|
+
"""
|
|
562
|
+
state = from_typer_context(typer_ctx)
|
|
563
|
+
fmt = resolve_output(state, output)
|
|
564
|
+
|
|
565
|
+
with make_client_or_exit(state) as client:
|
|
566
|
+
try:
|
|
567
|
+
invoice = client.account.topup_status(invoice_id)
|
|
568
|
+
except ApiError as exc:
|
|
569
|
+
_exit_on_api_error(exc)
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
_render_topup_invoice(
|
|
573
|
+
invoice,
|
|
574
|
+
fmt=fmt,
|
|
575
|
+
title=f"Top-up invoice {invoice_id}",
|
|
576
|
+
)
|