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.
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 "-"