avr-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.
- avr_cli-0.1.0.dist-info/METADATA +298 -0
- avr_cli-0.1.0.dist-info/RECORD +42 -0
- avr_cli-0.1.0.dist-info/WHEEL +5 -0
- avr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- avr_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- avr_cli-0.1.0.dist-info/top_level.txt +1 -0
- avrea_cli/__init__.py +9 -0
- avrea_cli/_build_info.py +2 -0
- avrea_cli/api_client.py +111 -0
- avrea_cli/assets/logo_mark.svg +25 -0
- avrea_cli/audit.py +153 -0
- avrea_cli/auth.py +457 -0
- avrea_cli/billing.py +614 -0
- avrea_cli/click_ext.py +261 -0
- avrea_cli/commands/__init__.py +1 -0
- avrea_cli/commands/auth_cmd.py +296 -0
- avrea_cli/commands/cache.py +312 -0
- avrea_cli/commands/config_cmd.py +180 -0
- avrea_cli/commands/health.py +76 -0
- avrea_cli/commands/internal.py +18 -0
- avrea_cli/commands/job.py +959 -0
- avrea_cli/commands/log.py +146 -0
- avrea_cli/commands/org.py +404 -0
- avrea_cli/commands/repo.py +152 -0
- avrea_cli/commands/run.py +1424 -0
- avrea_cli/commands/settings.py +271 -0
- avrea_cli/commands/status.py +405 -0
- avrea_cli/commands/workflow.py +883 -0
- avrea_cli/config.py +49 -0
- avrea_cli/display.py +431 -0
- avrea_cli/docs_gen.py +859 -0
- avrea_cli/firewall.py +342 -0
- avrea_cli/helpers.py +218 -0
- avrea_cli/json_output.py +169 -0
- avrea_cli/log_display.py +509 -0
- avrea_cli/main.py +210 -0
- avrea_cli/metrics_display.py +347 -0
- avrea_cli/output.py +101 -0
- avrea_cli/paths.py +45 -0
- avrea_cli/repo_context.py +473 -0
- avrea_cli/transport.py +71 -0
- avrea_cli/version.py +64 -0
avrea_cli/billing.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""Billing CLI commands."""
|
|
2
|
+
|
|
3
|
+
from avrea_cli.api_client import ApiClient
|
|
4
|
+
from avrea_cli.config import CliConfig
|
|
5
|
+
from avrea_cli.helpers import ensure_authenticated
|
|
6
|
+
from avrea_cli.helpers import ensure_ctx
|
|
7
|
+
from avrea_cli.helpers import ensure_prompts_allowed
|
|
8
|
+
from avrea_cli.helpers import get_org_id
|
|
9
|
+
from avrea_cli.helpers import handle_http_error
|
|
10
|
+
from avrea_cli.helpers import validate_cursor
|
|
11
|
+
from avrea_cli.json_output import emit_json
|
|
12
|
+
from avrea_cli.json_output import emit_json_record
|
|
13
|
+
from avrea_cli.json_output import handle_json_meta
|
|
14
|
+
from avrea_cli.json_output import json_options
|
|
15
|
+
from avrea_cli.json_output import make_schema
|
|
16
|
+
from avrea_cli.json_output import split_fields
|
|
17
|
+
from avrea_cli.output import format_key_value
|
|
18
|
+
from avrea_cli.output import output_list
|
|
19
|
+
from typing import Any
|
|
20
|
+
import click
|
|
21
|
+
import httpx
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
|
|
25
|
+
_BILLING_SUMMARY_FIELDS = make_schema("has_billing", "billing_emails", "default_payment_method")
|
|
26
|
+
_INVOICE_LIST_FIELDS = make_schema(
|
|
27
|
+
"invoice_id",
|
|
28
|
+
"period_start",
|
|
29
|
+
"period_end",
|
|
30
|
+
"subtotal_cents",
|
|
31
|
+
"tax_cents",
|
|
32
|
+
"total_cents",
|
|
33
|
+
"currency",
|
|
34
|
+
"status",
|
|
35
|
+
"has_pdf",
|
|
36
|
+
"created_at",
|
|
37
|
+
)
|
|
38
|
+
_INVOICE_VIEW_FIELDS = {**_INVOICE_LIST_FIELDS, **make_schema("line_items")}
|
|
39
|
+
_PAYMENT_METHOD_FIELDS = make_schema(
|
|
40
|
+
"payment_method_id", "card_brand", "card_last4", "card_exp_month", "card_exp_year", "is_default"
|
|
41
|
+
)
|
|
42
|
+
_BILLING_SETTINGS_FIELDS = make_schema(
|
|
43
|
+
"billing_emails", "tax_id", "billing_address", "stripe_customer_id", "metronome_customer_id"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def billing(ctx):
|
|
50
|
+
"""Manage billing, invoices, and payment methods."""
|
|
51
|
+
ensure_ctx(ctx)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@billing.command("summary")
|
|
55
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
56
|
+
@json_options
|
|
57
|
+
@click.pass_context
|
|
58
|
+
def billing_summary(ctx, org_id, json_fields, jq_expr):
|
|
59
|
+
"""Show billing summary for the organization.
|
|
60
|
+
|
|
61
|
+
\b
|
|
62
|
+
JSON FIELDS
|
|
63
|
+
billing_emails, default_payment_method, has_billing
|
|
64
|
+
"""
|
|
65
|
+
if handle_json_meta(json_fields, jq_expr, _BILLING_SUMMARY_FIELDS):
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
client: ApiClient = ctx.obj["client"]
|
|
69
|
+
config: CliConfig = ctx.obj["config"]
|
|
70
|
+
ensure_authenticated(config)
|
|
71
|
+
org_id = get_org_id(config, org_id)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
response = client.public_get(f"/orgs/{org_id}/billing")
|
|
75
|
+
data = response["data"]
|
|
76
|
+
except httpx.HTTPStatusError as exc:
|
|
77
|
+
handle_http_error(exc, "fetch billing summary")
|
|
78
|
+
|
|
79
|
+
if json_fields is not None:
|
|
80
|
+
emit_json_record(data, split_fields(json_fields, _BILLING_SUMMARY_FIELDS), _BILLING_SUMMARY_FIELDS, jq_expr)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
if not data.get("has_billing"):
|
|
84
|
+
click.echo("Billing is not configured for this organization.")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
record = {"Billing": "enabled", "Email": ", ".join(data.get("billing_emails") or []) or "-"}
|
|
88
|
+
pm = data.get("default_payment_method")
|
|
89
|
+
if pm:
|
|
90
|
+
record["Default Card"] = f"{pm.get('card_brand', '?')} ****{pm.get('card_last4', '????')}"
|
|
91
|
+
exp = f"{pm.get('card_exp_month', '?')}/{pm.get('card_exp_year', '?')}"
|
|
92
|
+
record["Card Expires"] = exp
|
|
93
|
+
else:
|
|
94
|
+
record["Default Card"] = "none"
|
|
95
|
+
|
|
96
|
+
click.echo(format_key_value(record))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@billing.group("invoices")
|
|
100
|
+
@click.pass_context
|
|
101
|
+
def invoices(ctx):
|
|
102
|
+
"""Manage invoices."""
|
|
103
|
+
ensure_ctx(ctx)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@invoices.command("list")
|
|
107
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
108
|
+
@click.option(
|
|
109
|
+
"-L",
|
|
110
|
+
"--limit",
|
|
111
|
+
type=click.IntRange(1, 100),
|
|
112
|
+
default=50,
|
|
113
|
+
show_default=True,
|
|
114
|
+
help="Max invoices to return.",
|
|
115
|
+
)
|
|
116
|
+
@click.option("--cursor", default=None, help="Pagination cursor from a previous response.")
|
|
117
|
+
@json_options
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def invoices_list(ctx, org_id, limit, cursor, json_fields, jq_expr):
|
|
120
|
+
"""List invoices.
|
|
121
|
+
|
|
122
|
+
\b
|
|
123
|
+
JSON FIELDS
|
|
124
|
+
created_at, currency, has_pdf, invoice_id, period_end, period_start,
|
|
125
|
+
status, subtotal_cents, tax_cents, total_cents
|
|
126
|
+
"""
|
|
127
|
+
if handle_json_meta(json_fields, jq_expr, _INVOICE_LIST_FIELDS):
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
client: ApiClient = ctx.obj["client"]
|
|
131
|
+
config: CliConfig = ctx.obj["config"]
|
|
132
|
+
ensure_authenticated(config)
|
|
133
|
+
org_id = get_org_id(config, org_id)
|
|
134
|
+
|
|
135
|
+
cursor = validate_cursor(cursor)
|
|
136
|
+
|
|
137
|
+
params: dict[str, Any] = {"limit": limit}
|
|
138
|
+
if cursor:
|
|
139
|
+
params["cursor"] = cursor
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
response = client.public_get(f"/orgs/{org_id}/billing/invoices", params=params)
|
|
143
|
+
data = response["data"]
|
|
144
|
+
except httpx.HTTPStatusError as exc:
|
|
145
|
+
handle_http_error(exc, "list invoices")
|
|
146
|
+
|
|
147
|
+
next_cursor = (response.get("pagination") or {}).get("next_cursor")
|
|
148
|
+
|
|
149
|
+
if json_fields is not None:
|
|
150
|
+
emit_json(data, split_fields(json_fields, _INVOICE_LIST_FIELDS), _INVOICE_LIST_FIELDS, jq_expr)
|
|
151
|
+
if next_cursor:
|
|
152
|
+
# Emit the cursor on a separate stream so scripts piping stdout into
|
|
153
|
+
# `jq` aren't broken, but the next page is still discoverable.
|
|
154
|
+
click.echo(f"next_cursor: {shlex.quote(next_cursor)}", err=True)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
for inv in data:
|
|
158
|
+
inv["total_display"] = _format_cents(inv.get("total_cents", 0), inv.get("currency", "usd"))
|
|
159
|
+
inv["period_display"] = f"{_format_date(inv.get('period_start'))} — {_format_date(inv.get('period_end'))}"
|
|
160
|
+
inv["pdf"] = "yes" if inv.get("has_pdf") else ""
|
|
161
|
+
|
|
162
|
+
output_list(
|
|
163
|
+
data,
|
|
164
|
+
columns=["invoice_id", "period_display", "total_display", "currency", "status", "pdf"],
|
|
165
|
+
column_labels=["Invoice ID", "Period", "Total", "Currency", "Status", "PDF"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if next_cursor:
|
|
169
|
+
click.echo(
|
|
170
|
+
f"\nMore results available. Next page: --cursor {shlex.quote(next_cursor)}",
|
|
171
|
+
err=True,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@invoices.command("show")
|
|
176
|
+
@click.argument("invoice_id")
|
|
177
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
178
|
+
@json_options
|
|
179
|
+
@click.pass_context
|
|
180
|
+
def invoices_show(ctx, invoice_id, org_id, json_fields, jq_expr):
|
|
181
|
+
"""Show details for a single invoice.
|
|
182
|
+
|
|
183
|
+
\b
|
|
184
|
+
JSON FIELDS
|
|
185
|
+
created_at, currency, has_pdf, invoice_id, line_items, period_end,
|
|
186
|
+
period_start, status, subtotal_cents, tax_cents, total_cents
|
|
187
|
+
"""
|
|
188
|
+
if handle_json_meta(json_fields, jq_expr, _INVOICE_VIEW_FIELDS):
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
client: ApiClient = ctx.obj["client"]
|
|
192
|
+
config: CliConfig = ctx.obj["config"]
|
|
193
|
+
ensure_authenticated(config)
|
|
194
|
+
org_id = get_org_id(config, org_id)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = client.public_get(f"/orgs/{org_id}/billing/invoices/{invoice_id}")
|
|
198
|
+
inv = response["data"]
|
|
199
|
+
except httpx.HTTPStatusError as exc:
|
|
200
|
+
handle_http_error(exc, "fetch invoice")
|
|
201
|
+
|
|
202
|
+
if json_fields is not None:
|
|
203
|
+
emit_json_record(inv, split_fields(json_fields, _INVOICE_VIEW_FIELDS), _INVOICE_VIEW_FIELDS, jq_expr)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
record = {
|
|
207
|
+
"Invoice ID": inv.get("invoice_id", "unknown"),
|
|
208
|
+
"Period": f"{_format_date(inv.get('period_start'))} — {_format_date(inv.get('period_end'))}",
|
|
209
|
+
"Subtotal": _format_cents(inv.get("subtotal_cents", 0), inv.get("currency", "usd")),
|
|
210
|
+
"Tax": _format_cents(inv.get("tax_cents", 0), inv.get("currency", "usd")),
|
|
211
|
+
"Total": _format_cents(inv.get("total_cents", 0), inv.get("currency", "usd")),
|
|
212
|
+
"Currency": inv.get("currency", "usd"),
|
|
213
|
+
"Status": inv.get("status", "unknown"),
|
|
214
|
+
"PDF Available": "yes" if inv.get("has_pdf") else "no",
|
|
215
|
+
"Created": inv.get("created_at", "unknown"),
|
|
216
|
+
}
|
|
217
|
+
click.echo(format_key_value(record))
|
|
218
|
+
|
|
219
|
+
line_items = inv.get("line_items", [])
|
|
220
|
+
if line_items:
|
|
221
|
+
currency = inv.get("currency", "usd")
|
|
222
|
+
for item in line_items:
|
|
223
|
+
item["unit_price_display"] = _format_cents(item.get("unit_price", 0), currency)
|
|
224
|
+
item["total_display"] = _format_cents(item.get("total", 0), currency)
|
|
225
|
+
click.echo()
|
|
226
|
+
output_list(
|
|
227
|
+
line_items,
|
|
228
|
+
columns=["name", "quantity", "unit_price_display", "total_display"],
|
|
229
|
+
column_labels=["Item", "Quantity", "Unit Price", "Total"],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@invoices.command("download")
|
|
234
|
+
@click.argument("invoice_id")
|
|
235
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
236
|
+
@click.option("--out", "out_path", default=None, help="Output file path. Defaults to <invoice_id>.pdf.")
|
|
237
|
+
@click.pass_context
|
|
238
|
+
def invoices_download(ctx, invoice_id, org_id, out_path):
|
|
239
|
+
"""Download an invoice PDF."""
|
|
240
|
+
config: CliConfig = ctx.obj["config"]
|
|
241
|
+
ensure_authenticated(config)
|
|
242
|
+
org_id = get_org_id(config, org_id)
|
|
243
|
+
|
|
244
|
+
url = f"{config.public_api_url}/orgs/{org_id}/billing/invoices/{invoice_id}/pdf"
|
|
245
|
+
try:
|
|
246
|
+
response = httpx.get(
|
|
247
|
+
url,
|
|
248
|
+
headers=config.get_api_headers(),
|
|
249
|
+
follow_redirects=True,
|
|
250
|
+
timeout=30.0,
|
|
251
|
+
)
|
|
252
|
+
response.raise_for_status()
|
|
253
|
+
except httpx.HTTPStatusError as exc:
|
|
254
|
+
handle_http_error(exc, "download invoice PDF")
|
|
255
|
+
|
|
256
|
+
safe_id = os.path.basename(invoice_id)
|
|
257
|
+
filename = out_path or f"{safe_id}.pdf"
|
|
258
|
+
try:
|
|
259
|
+
with open(filename, "wb") as f:
|
|
260
|
+
f.write(response.content)
|
|
261
|
+
click.echo(f"Invoice PDF saved to {filename}")
|
|
262
|
+
except OSError as e:
|
|
263
|
+
click.echo(f"Error writing file: {e}", err=True)
|
|
264
|
+
raise click.Abort() from None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@billing.group("payment-methods")
|
|
268
|
+
@click.pass_context
|
|
269
|
+
def payment_methods(ctx):
|
|
270
|
+
"""Manage payment methods."""
|
|
271
|
+
ensure_ctx(ctx)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@payment_methods.command("list")
|
|
275
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
276
|
+
@json_options
|
|
277
|
+
@click.pass_context
|
|
278
|
+
def payment_methods_list(ctx, org_id, json_fields, jq_expr):
|
|
279
|
+
"""List payment methods.
|
|
280
|
+
|
|
281
|
+
\b
|
|
282
|
+
JSON FIELDS
|
|
283
|
+
card_brand, card_exp_month, card_exp_year, card_last4, is_default,
|
|
284
|
+
payment_method_id
|
|
285
|
+
"""
|
|
286
|
+
if handle_json_meta(json_fields, jq_expr, _PAYMENT_METHOD_FIELDS):
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
client: ApiClient = ctx.obj["client"]
|
|
290
|
+
config: CliConfig = ctx.obj["config"]
|
|
291
|
+
ensure_authenticated(config)
|
|
292
|
+
org_id = get_org_id(config, org_id)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
response = client.public_get(f"/orgs/{org_id}/billing/payment-methods")
|
|
296
|
+
data = response["data"]
|
|
297
|
+
except httpx.HTTPStatusError as exc:
|
|
298
|
+
handle_http_error(exc, "list payment methods")
|
|
299
|
+
|
|
300
|
+
if json_fields is not None:
|
|
301
|
+
emit_json(data, split_fields(json_fields, _PAYMENT_METHOD_FIELDS), _PAYMENT_METHOD_FIELDS, jq_expr)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
for pm in data:
|
|
305
|
+
pm["card_display"] = f"{pm.get('card_brand', '?')} ****{pm.get('card_last4', '????')}"
|
|
306
|
+
pm["expires"] = f"{pm.get('card_exp_month', '?')}/{pm.get('card_exp_year', '?')}"
|
|
307
|
+
pm["default"] = "yes" if pm.get("is_default") else ""
|
|
308
|
+
|
|
309
|
+
output_list(
|
|
310
|
+
data,
|
|
311
|
+
columns=["payment_method_id", "card_display", "expires", "default"],
|
|
312
|
+
column_labels=["ID", "Card", "Expires", "Default"],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@payment_methods.command("add")
|
|
317
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
318
|
+
@click.option(
|
|
319
|
+
"--number",
|
|
320
|
+
required=True,
|
|
321
|
+
prompt="Card number",
|
|
322
|
+
hide_input=True,
|
|
323
|
+
help="Credit card number. Prefer prompting over --number to avoid shell history.",
|
|
324
|
+
)
|
|
325
|
+
@click.option(
|
|
326
|
+
"--exp-month",
|
|
327
|
+
required=True,
|
|
328
|
+
prompt="Expiration month (1-12)",
|
|
329
|
+
type=click.IntRange(1, 12),
|
|
330
|
+
help="Card expiration month.",
|
|
331
|
+
)
|
|
332
|
+
@click.option("--exp-year", required=True, prompt="Expiration year (e.g. 2027)", type=int, help="Card expiration year.")
|
|
333
|
+
@click.option(
|
|
334
|
+
"--cvc",
|
|
335
|
+
required=True,
|
|
336
|
+
prompt="CVC",
|
|
337
|
+
hide_input=True,
|
|
338
|
+
help="Card CVC/CVV code. Prefer prompting over --cvc to avoid shell history.",
|
|
339
|
+
)
|
|
340
|
+
@click.pass_context
|
|
341
|
+
def payment_methods_add(ctx, org_id, number, exp_month, exp_year, cvc):
|
|
342
|
+
"""Add a credit card as a payment method.
|
|
343
|
+
|
|
344
|
+
Card details are sent directly to Stripe and never touch Avrea servers.
|
|
345
|
+
"""
|
|
346
|
+
client: ApiClient = ctx.obj["client"]
|
|
347
|
+
config: CliConfig = ctx.obj["config"]
|
|
348
|
+
ensure_authenticated(config)
|
|
349
|
+
org_id = get_org_id(config, org_id)
|
|
350
|
+
|
|
351
|
+
# Get Stripe publishable key
|
|
352
|
+
try:
|
|
353
|
+
key_resp = client.public_get(f"/orgs/{org_id}/billing/stripe-key")
|
|
354
|
+
stripe_key = key_resp["data"]["stripe_key"]
|
|
355
|
+
except httpx.HTTPStatusError as exc:
|
|
356
|
+
handle_http_error(exc, "fetch Stripe key")
|
|
357
|
+
|
|
358
|
+
# Create a SetupIntent
|
|
359
|
+
try:
|
|
360
|
+
intent_resp = client.public_post(f"/orgs/{org_id}/billing/setup-intent")
|
|
361
|
+
client_secret = intent_resp["data"]["client_secret"]
|
|
362
|
+
except httpx.HTTPStatusError as exc:
|
|
363
|
+
handle_http_error(exc, "create setup intent")
|
|
364
|
+
|
|
365
|
+
# Confirm the SetupIntent with card details directly to Stripe
|
|
366
|
+
parts = client_secret.rsplit("_secret_", 1)
|
|
367
|
+
if len(parts) != 2 or not parts[0].startswith("seti_"):
|
|
368
|
+
click.echo("Error: Invalid SetupIntent client secret format.", err=True)
|
|
369
|
+
raise click.Abort()
|
|
370
|
+
setup_intent_id = parts[0]
|
|
371
|
+
try:
|
|
372
|
+
stripe_resp = httpx.post(
|
|
373
|
+
f"https://api.stripe.com/v1/setup_intents/{setup_intent_id}/confirm",
|
|
374
|
+
auth=(stripe_key, ""),
|
|
375
|
+
data={
|
|
376
|
+
"payment_method_data[type]": "card",
|
|
377
|
+
"payment_method_data[card][number]": number,
|
|
378
|
+
"payment_method_data[card][exp_month]": exp_month,
|
|
379
|
+
"payment_method_data[card][exp_year]": exp_year,
|
|
380
|
+
"payment_method_data[card][cvc]": cvc,
|
|
381
|
+
"client_secret": client_secret,
|
|
382
|
+
},
|
|
383
|
+
timeout=30.0,
|
|
384
|
+
)
|
|
385
|
+
stripe_resp.raise_for_status()
|
|
386
|
+
except httpx.HTTPStatusError as exc:
|
|
387
|
+
detail = ""
|
|
388
|
+
try:
|
|
389
|
+
body = exc.response.json()
|
|
390
|
+
detail = body.get("error", {}).get("message", "")
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
click.echo(f"Error: Stripe rejected the card details.{f' ({detail})' if detail else ''}", err=True)
|
|
394
|
+
raise click.Abort() from None
|
|
395
|
+
except httpx.RequestError as e:
|
|
396
|
+
click.echo(f"Error: Failed to connect to Stripe: {e}", err=True)
|
|
397
|
+
raise click.Abort() from None
|
|
398
|
+
|
|
399
|
+
pm_id = stripe_resp.json().get("payment_method")
|
|
400
|
+
if not pm_id:
|
|
401
|
+
click.echo("Error: No payment method returned from Stripe.", err=True)
|
|
402
|
+
raise click.Abort()
|
|
403
|
+
|
|
404
|
+
# Attach to our backend
|
|
405
|
+
try:
|
|
406
|
+
response = client.public_post(
|
|
407
|
+
f"/orgs/{org_id}/billing/payment-methods",
|
|
408
|
+
json={"stripe_payment_method_id": pm_id},
|
|
409
|
+
)
|
|
410
|
+
pm = response["data"]
|
|
411
|
+
except httpx.HTTPStatusError as exc:
|
|
412
|
+
handle_http_error(exc, "attach payment method")
|
|
413
|
+
|
|
414
|
+
click.echo(
|
|
415
|
+
f"Card added: {pm.get('card_brand', '?')} ****{pm.get('card_last4', '????')} "
|
|
416
|
+
f"(expires {pm.get('card_exp_month', '?')}/{pm.get('card_exp_year', '?')})"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@payment_methods.command("set-default")
|
|
421
|
+
@click.argument("pm_id")
|
|
422
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
423
|
+
@click.pass_context
|
|
424
|
+
def payment_methods_set_default(ctx, pm_id, org_id):
|
|
425
|
+
"""Set a payment method as the default."""
|
|
426
|
+
client: ApiClient = ctx.obj["client"]
|
|
427
|
+
config: CliConfig = ctx.obj["config"]
|
|
428
|
+
ensure_authenticated(config)
|
|
429
|
+
org_id = get_org_id(config, org_id)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
response = client.public_put(f"/orgs/{org_id}/billing/payment-methods/{pm_id}/default")
|
|
433
|
+
pm = response["data"]
|
|
434
|
+
except httpx.HTTPStatusError as exc:
|
|
435
|
+
handle_http_error(exc, "set default payment method")
|
|
436
|
+
|
|
437
|
+
click.echo(f"Default payment method set to {pm.get('card_brand', '?')} ****{pm.get('card_last4', '????')}")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@payment_methods.command("remove")
|
|
441
|
+
@click.argument("pm_id")
|
|
442
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
443
|
+
@click.option("--yes", "confirmed", is_flag=True, help="Skip confirmation prompt.")
|
|
444
|
+
@click.pass_context
|
|
445
|
+
def payment_methods_remove(ctx, pm_id, org_id, confirmed):
|
|
446
|
+
"""Remove a payment method."""
|
|
447
|
+
client: ApiClient = ctx.obj["client"]
|
|
448
|
+
config: CliConfig = ctx.obj["config"]
|
|
449
|
+
ensure_authenticated(config)
|
|
450
|
+
org_id = get_org_id(config, org_id)
|
|
451
|
+
|
|
452
|
+
if not confirmed:
|
|
453
|
+
ensure_prompts_allowed("payment-method remove needs confirmation")
|
|
454
|
+
click.confirm(f"Remove payment method {pm_id}?", abort=True)
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
client.public_delete(f"/orgs/{org_id}/billing/payment-methods/{pm_id}")
|
|
458
|
+
except httpx.HTTPStatusError as exc:
|
|
459
|
+
handle_http_error(exc, "remove payment method")
|
|
460
|
+
|
|
461
|
+
click.echo(f"Payment method {pm_id} removed.")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@billing.command("settings")
|
|
465
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
466
|
+
@json_options
|
|
467
|
+
@click.pass_context
|
|
468
|
+
def billing_settings(ctx, org_id, json_fields, jq_expr):
|
|
469
|
+
"""Show billing settings.
|
|
470
|
+
|
|
471
|
+
\b
|
|
472
|
+
JSON FIELDS
|
|
473
|
+
billing_address, billing_emails, metronome_customer_id,
|
|
474
|
+
stripe_customer_id, tax_id
|
|
475
|
+
"""
|
|
476
|
+
if handle_json_meta(json_fields, jq_expr, _BILLING_SETTINGS_FIELDS):
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
client: ApiClient = ctx.obj["client"]
|
|
480
|
+
config: CliConfig = ctx.obj["config"]
|
|
481
|
+
ensure_authenticated(config)
|
|
482
|
+
org_id = get_org_id(config, org_id)
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
response = client.public_get(f"/orgs/{org_id}/billing/settings")
|
|
486
|
+
data = response["data"]
|
|
487
|
+
except httpx.HTTPStatusError as exc:
|
|
488
|
+
handle_http_error(exc, "fetch billing settings")
|
|
489
|
+
|
|
490
|
+
if json_fields is not None:
|
|
491
|
+
emit_json_record(data, split_fields(json_fields, _BILLING_SETTINGS_FIELDS), _BILLING_SETTINGS_FIELDS, jq_expr)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
click.echo(
|
|
495
|
+
format_key_value(
|
|
496
|
+
{
|
|
497
|
+
"Email": ", ".join(data.get("billing_emails") or []) or "-",
|
|
498
|
+
"Tax ID": data.get("tax_id") or "-",
|
|
499
|
+
"Address": _format_address(data.get("billing_address")),
|
|
500
|
+
"Stripe Customer": data.get("stripe_customer_id") or "-",
|
|
501
|
+
"Metronome Customer": data.get("metronome_customer_id") or "-",
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@billing.command("update-settings")
|
|
508
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
509
|
+
@click.option("--email", "billing_email", default=None, help="Billing email address(es), comma-separated.")
|
|
510
|
+
@click.option("--tax-id", default=None, help="Tax ID (e.g. VAT number).")
|
|
511
|
+
@json_options
|
|
512
|
+
@click.pass_context
|
|
513
|
+
def billing_update_settings(ctx, org_id, billing_email, tax_id, json_fields, jq_expr):
|
|
514
|
+
"""Update billing settings.
|
|
515
|
+
|
|
516
|
+
\b
|
|
517
|
+
Examples:
|
|
518
|
+
avr billing update-settings --email billing@example.com
|
|
519
|
+
avr billing update-settings --tax-id EU123456789
|
|
520
|
+
avr billing update-settings --email a@example.com,b@example.com --tax-id FI12345678
|
|
521
|
+
|
|
522
|
+
\b
|
|
523
|
+
JSON FIELDS
|
|
524
|
+
billing_address, billing_emails, metronome_customer_id,
|
|
525
|
+
stripe_customer_id, tax_id
|
|
526
|
+
"""
|
|
527
|
+
if handle_json_meta(json_fields, jq_expr, _BILLING_SETTINGS_FIELDS):
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
client: ApiClient = ctx.obj["client"]
|
|
531
|
+
config: CliConfig = ctx.obj["config"]
|
|
532
|
+
ensure_authenticated(config)
|
|
533
|
+
org_id = get_org_id(config, org_id)
|
|
534
|
+
|
|
535
|
+
if billing_email is None and tax_id is None:
|
|
536
|
+
click.echo("Error: Provide at least one of --email or --tax-id", err=True)
|
|
537
|
+
raise click.Abort()
|
|
538
|
+
|
|
539
|
+
body = {}
|
|
540
|
+
if billing_email is not None:
|
|
541
|
+
body["billing_emails"] = [e.strip() for e in billing_email.split(",") if e.strip()]
|
|
542
|
+
if tax_id is not None:
|
|
543
|
+
body["tax_id"] = tax_id
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
response = client.public_put(f"/orgs/{org_id}/billing/settings", json=body)
|
|
547
|
+
data = response["data"]
|
|
548
|
+
except httpx.HTTPStatusError as exc:
|
|
549
|
+
handle_http_error(exc, "update billing settings")
|
|
550
|
+
|
|
551
|
+
if json_fields is not None:
|
|
552
|
+
emit_json_record(data, split_fields(json_fields, _BILLING_SETTINGS_FIELDS), _BILLING_SETTINGS_FIELDS, jq_expr)
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
click.echo(
|
|
556
|
+
format_key_value(
|
|
557
|
+
{
|
|
558
|
+
"Email": ", ".join(data.get("billing_emails") or []) or "-",
|
|
559
|
+
"Tax ID": data.get("tax_id") or "-",
|
|
560
|
+
"Address": _format_address(data.get("billing_address")),
|
|
561
|
+
"Stripe Customer": data.get("stripe_customer_id") or "-",
|
|
562
|
+
"Metronome Customer": data.get("metronome_customer_id") or "-",
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
_CURRENCY_SYMBOLS = {"usd": "$", "eur": "€", "gbp": "£", "jpy": "¥", "chf": "CHF "}
|
|
569
|
+
# Full list from Stripe docs: https://docs.stripe.com/currencies#zero-decimal
|
|
570
|
+
_ZERO_DECIMAL_CURRENCIES = {
|
|
571
|
+
"bif",
|
|
572
|
+
"clp",
|
|
573
|
+
"djf",
|
|
574
|
+
"gnf",
|
|
575
|
+
"jpy",
|
|
576
|
+
"kmf",
|
|
577
|
+
"krw",
|
|
578
|
+
"mga",
|
|
579
|
+
"pyg",
|
|
580
|
+
"rwf",
|
|
581
|
+
"ugx",
|
|
582
|
+
"vnd",
|
|
583
|
+
"vuv",
|
|
584
|
+
"xaf",
|
|
585
|
+
"xof",
|
|
586
|
+
"xpf",
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _format_cents(cents: int, currency: str = "usd") -> str:
|
|
591
|
+
symbol = _CURRENCY_SYMBOLS.get(currency.lower(), f"{currency.upper()} ")
|
|
592
|
+
if currency.lower() in _ZERO_DECIMAL_CURRENCIES:
|
|
593
|
+
return f"{symbol}{cents}"
|
|
594
|
+
return f"{symbol}{cents / 100:.2f}"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _format_date(value: str | None) -> str:
|
|
598
|
+
if not value:
|
|
599
|
+
return "?"
|
|
600
|
+
return value[:10]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _format_address(addr: dict | None) -> str:
|
|
604
|
+
if not addr:
|
|
605
|
+
return "-"
|
|
606
|
+
parts = [
|
|
607
|
+
addr.get("line1"),
|
|
608
|
+
addr.get("line2"),
|
|
609
|
+
addr.get("city"),
|
|
610
|
+
addr.get("state"),
|
|
611
|
+
addr.get("postal_code"),
|
|
612
|
+
addr.get("country"),
|
|
613
|
+
]
|
|
614
|
+
return ", ".join(p for p in parts if p) or "-"
|