extendvcc-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.
extendvcc/cli.py ADDED
@@ -0,0 +1,883 @@
1
+ """extendvcc CLI — full lifecycle management of Extend virtual cards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import getpass
8
+ import json
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from ._exit_codes import (
17
+ EXIT_API_ERROR,
18
+ EXIT_AUTH_REQUIRED,
19
+ EXIT_DISABLED,
20
+ EXIT_ERROR,
21
+ EXIT_OK,
22
+ EXIT_USAGE,
23
+ )
24
+
25
+ DISCLAIMER = (
26
+ "Unofficial and unaffiliated: extendvcc is an independent client for "
27
+ "Extend's private API (api.paywithextend.com). It is not affiliated with, endorsed "
28
+ "by, or supported by Extend, Inc. Automating their private API may get your account "
29
+ "suspended. Use at your own risk."
30
+ )
31
+
32
+
33
+ class CLIInputError(ValueError):
34
+ """Raised for CLI-layer input/usage errors (maps to EXIT_USAGE).
35
+
36
+ Distinct from library ``ValueError`` (e.g. ``usage()`` missing org_id) so that
37
+ only CLI-owned validation maps to exit 2; library errors fall through to 1.
38
+ """
39
+
40
+
41
+ def _info(msg: str = "") -> None:
42
+ """Print a human-oriented message to stderr.
43
+
44
+ Under ``--json``, stdout must carry only structured JSON, so every progress
45
+ line, confirmation, summary, and warning routes through here to stderr.
46
+ """
47
+ print(msg, file=sys.stderr)
48
+
49
+
50
+ def _json_out(data: Any) -> str:
51
+ """Serialize data for --json output."""
52
+ return json.dumps(data, indent=2, sort_keys=True, default=str)
53
+
54
+
55
+ def _confirm(prompt: str, *, yes: bool = False) -> bool:
56
+ """Ask for confirmation unless --yes was passed."""
57
+ if yes:
58
+ return True
59
+ # Prompt to stderr so stdout stays clean (shell pipes, --json capture).
60
+ print(prompt, end="", file=sys.stderr, flush=True)
61
+ answer = input()
62
+ return answer.strip().lower() in ("y", "yes")
63
+
64
+
65
+ def _mask_card_number(number: str) -> str:
66
+ """Show first 4 and last 4 digits, mask the rest."""
67
+ if len(number) <= 8:
68
+ return number
69
+ return number[:4] + "*" * (len(number) - 8) + number[-4:]
70
+
71
+
72
+ def _card_to_dict(card: Any) -> dict[str, Any]:
73
+ """Convert a dataclass card to a JSON-safe dict."""
74
+ return json.loads(json.dumps(asdict(card), default=str))
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Command handlers
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _prompt(prompt: str) -> str:
83
+ """Read a line from stdin with the prompt written to stderr (keeps stdout clean)."""
84
+ print(prompt, end="", file=sys.stderr, flush=True)
85
+ return input()
86
+
87
+
88
+ def _cmd_login(args: argparse.Namespace) -> int:
89
+ from . import auth
90
+ from .imap_otp import make_otp_callback
91
+
92
+ # Surface the unofficial/at-your-own-risk notice at the start of real usage.
93
+ _info(DISCLAIMER)
94
+ _info()
95
+
96
+ email = args.email or os.environ.get("EXTENDVCC_EMAIL") or _prompt("Email: ")
97
+ # getpass writes its prompt to stderr, so stdout stays clean.
98
+ password = os.environ.get("EXTENDVCC_PASSWORD") or getpass.getpass("Password: ")
99
+
100
+ # Pass credentials directly — never write the plaintext password into the
101
+ # process environment, where it would leak into every child process.
102
+ otp_callback = make_otp_callback()
103
+ result = auth.setup(email=email, password=password, otp_callback=otp_callback)
104
+
105
+ if getattr(args, "json", False):
106
+ print(_json_out(result))
107
+ else:
108
+ _info(f"Logged in as {result.get('email', '?')}")
109
+ if result.get("org_id"):
110
+ _info(f"Organization: {result['org_id']}")
111
+ _info(f"Session saved to {result.get('session_path', '?')}")
112
+ return EXIT_OK
113
+
114
+
115
+ def _cmd_accounts(args: argparse.Namespace) -> int:
116
+ from .cards import list_credit_cards
117
+
118
+ cards = list_credit_cards()
119
+ if getattr(args, "json", False):
120
+ print(_json_out([_card_to_dict(c) for c in cards]))
121
+ else:
122
+ if not cards:
123
+ _info("No enrolled credit cards.")
124
+ return EXIT_OK
125
+ print(f"{'ID':<40} {'Last 4':<8} {'Status':<16} {'Name'}")
126
+ print("-" * 90)
127
+ for c in cards:
128
+ print(f"{c.id:<40} {c.last4:<8} {c.status.value:<16} {c.display_name}")
129
+ return EXIT_OK
130
+
131
+
132
+ def _cmd_enroll(args: argparse.Namespace) -> int:
133
+ from .cards import enroll_credit_card
134
+
135
+ card_number = getpass.getpass("Card number (PAN): ")
136
+ cvc = getpass.getpass("CVC: ")
137
+
138
+ _info(f"\nEnrolling card ending in ...{card_number[-4:]}")
139
+ _info(f" Name: {args.display_name}")
140
+ _info(f" Cardholder: {args.cardholder_name}")
141
+ _info(f" Issuer ID: {args.issuer_id}")
142
+ if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
143
+ _info("Cancelled.")
144
+ return EXIT_ERROR
145
+
146
+ address = {
147
+ "address1": args.address1,
148
+ "address2": getattr(args, "address2", "") or "",
149
+ "city": args.city,
150
+ "province": args.province,
151
+ "postal": args.postal,
152
+ }
153
+
154
+ result = enroll_credit_card(
155
+ display_name=args.display_name,
156
+ card_number=card_number,
157
+ expires=args.expires,
158
+ cvc=cvc,
159
+ cardholder_name=args.cardholder_name,
160
+ issuer_id=args.issuer_id,
161
+ address=address,
162
+ company_name=getattr(args, "company_name", None),
163
+ country=getattr(args, "country", "US") or "US",
164
+ )
165
+ if getattr(args, "json", False):
166
+ print(_json_out(_card_to_dict(result)))
167
+ else:
168
+ print(f"Enrolled: {result.id} (last4={result.last4}, status={result.status.value})")
169
+ _info("Check your email for issuer verification, then run: extendvcc activate <id>")
170
+ return EXIT_OK
171
+
172
+
173
+ def _cmd_activate(args: argparse.Namespace) -> int:
174
+ from .cards import activate_credit_card
175
+ from .models import CardStatus
176
+
177
+ card = activate_credit_card(args.id)
178
+ if getattr(args, "json", False):
179
+ print(_json_out(_card_to_dict(card)))
180
+ else:
181
+ print(f"Card: {card.id}")
182
+ print(f"Status: {card.status.value}")
183
+ if card.status == CardStatus.PENDING:
184
+ _info("Still PENDING — verify via the issuer email, then re-run: extendvcc activate <id>")
185
+ return EXIT_OK
186
+
187
+
188
+ def _cmd_issuers(args: argparse.Namespace) -> int:
189
+ from .cards import list_issuers
190
+
191
+ issuers = list_issuers()
192
+ if getattr(args, "json", False):
193
+ print(_json_out([_card_to_dict(i) for i in issuers]))
194
+ else:
195
+ if not issuers:
196
+ _info("No issuers found.")
197
+ return EXIT_OK
198
+ print(f"{'ID':<40} {'Code':<12} {'Name'}")
199
+ print("-" * 70)
200
+ for i in issuers:
201
+ print(f"{i.id:<40} {i.code:<12} {i.name}")
202
+ return EXIT_OK
203
+
204
+
205
+ def _cmd_cards(args: argparse.Namespace) -> int:
206
+ from .cards import list_cards
207
+ from .models import CardStatus
208
+
209
+ status = None
210
+ if args.status:
211
+ status = CardStatus(args.status.upper())
212
+
213
+ cards = list_cards(status=status)
214
+ if getattr(args, "json", False):
215
+ print(_json_out([_card_to_dict(c) for c in cards]))
216
+ else:
217
+ if not cards:
218
+ _info("No virtual cards found.")
219
+ return EXIT_OK
220
+ print(f"{'ID':<40} {'Last 4':<8} {'Status':<14} {'Balance':<12} {'Name'}")
221
+ print("-" * 120)
222
+ for c in cards:
223
+ balance = f"${c.balance_cents / 100:.2f}"
224
+ print(f"{c.id:<40} {c.last4:<8} {c.status.value:<14} {balance:<12} {c.name}")
225
+ return EXIT_OK
226
+
227
+
228
+ def _cmd_card(args: argparse.Namespace) -> int:
229
+ from .cards import get_card
230
+
231
+ card = get_card(args.id)
232
+ if getattr(args, "json", False):
233
+ print(_json_out(_card_to_dict(card)))
234
+ else:
235
+ print(f"ID: {card.id}")
236
+ print(f"Name: {card.name}")
237
+ print(f"Last 4: {card.last4}")
238
+ print(f"Status: {card.status.value}")
239
+ print(f"Balance: ${card.balance_cents / 100:.2f}")
240
+ print(f"Credit Card: {card.credit_card_id}")
241
+ print(f"Valid From: {card.valid_from or 'N/A'}")
242
+ print(f"Valid To: {card.valid_to or 'N/A'}")
243
+ print(f"Created: {card.created_at or 'N/A'}")
244
+ if card.notes:
245
+ print(f"Notes: {card.notes}")
246
+ return EXIT_OK
247
+
248
+
249
+ def _cmd_usage(args: argparse.Namespace) -> int:
250
+ from .cards import usage
251
+
252
+ result = usage()
253
+ if getattr(args, "json", False):
254
+ print(_json_out(result))
255
+ else:
256
+ print(f"Active cards: {result['used']} / {result['limit']}")
257
+ print(f"Remaining: {result['remaining']}")
258
+ return EXIT_OK
259
+
260
+
261
+ def _create_recurrence(args: argparse.Namespace) -> Any:
262
+ from .models import Recurrence
263
+
264
+ if not args.period:
265
+ return None
266
+ return Recurrence(
267
+ period=args.period.upper(),
268
+ interval=getattr(args, "interval", 1) or 1,
269
+ terminator=(getattr(args, "terminator", None) or "NONE").upper(),
270
+ by_month_day=getattr(args, "by_month_day", None),
271
+ by_week_day=getattr(args, "by_week_day", None),
272
+ until=getattr(args, "until", None),
273
+ count=getattr(args, "count", None),
274
+ )
275
+
276
+
277
+ def _create_summary(args: argparse.Namespace, recurrence: Any) -> None:
278
+ kind = "recurring" if recurrence else "one-time"
279
+ balance_dollars = args.balance_cents / 100
280
+ _info(f"Creating {kind} virtual card:")
281
+ _info(f" Name: {args.name}")
282
+ _info(f" Balance: ${balance_dollars:.2f}")
283
+ _info(f" Credit Card ID: {args.credit_card_id}")
284
+ if recurrence:
285
+ _info(f" Period: {recurrence.period} (every {recurrence.interval})")
286
+ _info(f" Terminator: {recurrence.terminator}")
287
+ else:
288
+ _info(f" Valid To: {args.valid_to}")
289
+
290
+
291
+ def _local_recipient(recipient_flag: str | None) -> tuple[str, bool]:
292
+ """Resolve a recipient WITHOUT any network call for dry-run preview.
293
+
294
+ Returns ``(email, exact)``. Prefers an explicit ``--recipient`` flag, else the
295
+ email from the locally-saved session file, else a placeholder. ``exact`` is
296
+ True only when we have a concrete value (flag or session); a placeholder means
297
+ the preview is approximate.
298
+ """
299
+ if recipient_flag:
300
+ return recipient_flag, True
301
+ from . import auth
302
+
303
+ session = auth.load_session()
304
+ if session and session.get("email"):
305
+ return str(session["email"]), True
306
+ return "<session-email>", False
307
+
308
+
309
+ def _cmd_create(args: argparse.Namespace) -> int:
310
+ from .cards import build_create_card_operation, create_card
311
+
312
+ if args.valid_to and args.period:
313
+ raise CLIInputError("--valid-to and --period are mutually exclusive.")
314
+ if not args.valid_to and not args.period:
315
+ raise CLIInputError("provide either --valid-to (one-time) or --period (recurring).")
316
+
317
+ recurrence = _create_recurrence(args)
318
+
319
+ if getattr(args, "dry_run", False):
320
+ return _create_dry_run(args, recurrence, build_create_card_operation)
321
+
322
+ _create_summary(args, recurrence)
323
+ if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
324
+ _info("Cancelled.")
325
+ return EXIT_ERROR
326
+
327
+ card = create_card(
328
+ credit_card_id=args.credit_card_id,
329
+ name=args.name,
330
+ balance_cents=args.balance_cents,
331
+ valid_to=args.valid_to if not recurrence else None,
332
+ recurrence=recurrence,
333
+ recipient=getattr(args, "recipient", None),
334
+ )
335
+ if getattr(args, "json", False):
336
+ print(_json_out(_card_to_dict(card)))
337
+ else:
338
+ print(f"Created: {card.id} (last4={card.last4}, status={card.status.value})")
339
+ return EXIT_OK
340
+
341
+
342
+ def _create_dry_run(args: argparse.Namespace, recurrence: Any, builder: Any) -> int:
343
+ """Preview a create without any network call: resolve recipient locally,
344
+ print the plan to stderr and the would-be request body (JSON) to stdout."""
345
+ import uuid
346
+
347
+ recipient, exact = _local_recipient(getattr(args, "recipient", None))
348
+ operation = builder(
349
+ args.credit_card_id,
350
+ args.name,
351
+ args.balance_cents,
352
+ args.valid_to if not recurrence else None,
353
+ recurrence=recurrence,
354
+ recipient_resolver=lambda: recipient,
355
+ token_factory=lambda: uuid.uuid4().hex[:8],
356
+ )
357
+ if not exact:
358
+ operation["preview_accuracy"] = "approximate"
359
+
360
+ _create_summary(args, recurrence)
361
+ _info(f" Recipient: {recipient}")
362
+ _info(f"[dry-run] No API call made (preview: {operation['preview_accuracy']}).")
363
+ print(_json_out(operation["body"]))
364
+ return EXIT_OK
365
+
366
+
367
+ def _read_bulk_rows(csv_path: Path) -> list[dict[str, Any]]:
368
+ with open(csv_path, newline="", encoding="utf-8") as f:
369
+ reader = csv.DictReader(f)
370
+ rows: list[dict[str, Any]] = []
371
+ for row in reader:
372
+ parsed: dict[str, Any] = {
373
+ "name": row["name"],
374
+ "balance_cents": int(row["balance_cents"]),
375
+ "valid_to": row["valid_to"],
376
+ }
377
+ if "recipient" in row and row["recipient"].strip():
378
+ parsed["recipient"] = row["recipient"].strip()
379
+ rows.append(parsed)
380
+ return rows
381
+
382
+
383
+ def _cmd_bulk(args: argparse.Namespace) -> int:
384
+ from .cards import build_create_card_operation, create_cards_bulk
385
+
386
+ csv_path = Path(args.file)
387
+ if not csv_path.exists():
388
+ raise CLIInputError(f"file not found: {csv_path}")
389
+
390
+ rows = _read_bulk_rows(csv_path)
391
+ if not rows:
392
+ raise CLIInputError("CSV file is empty.")
393
+
394
+ total_cents = sum(r["balance_cents"] for r in rows)
395
+ _info(f"Bulk create: {len(rows)} cards, total ${total_cents / 100:.2f}")
396
+ _info(f" Credit Card ID: {args.credit_card_id}")
397
+
398
+ if getattr(args, "dry_run", False):
399
+ return _bulk_dry_run(args, rows, build_create_card_operation)
400
+
401
+ _info(f" Delay: {args.delay}s (jitter {args.jitter}s, min {args.min_delay}s)")
402
+ if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
403
+ _info("Cancelled.")
404
+ return EXIT_ERROR
405
+
406
+ cards = create_cards_bulk(
407
+ credit_card_id=args.credit_card_id,
408
+ rows=rows,
409
+ delay_seconds=args.delay,
410
+ jitter_seconds=args.jitter,
411
+ min_delay_seconds=args.min_delay,
412
+ )
413
+ if getattr(args, "json", False):
414
+ print(_json_out([_card_to_dict(c) for c in cards]))
415
+ else:
416
+ for c in cards:
417
+ print(f" Created: {c.id} (last4={c.last4}, {c.name})")
418
+ print(f"\n{len(cards)} cards created.")
419
+ return EXIT_OK
420
+
421
+
422
+ def _bulk_dry_run(args: argparse.Namespace, rows: list[dict[str, Any]], builder: Any) -> int:
423
+ """Preview a bulk create with no network calls and no pacing sleeps.
424
+
425
+ Each row's recipient is resolved locally (flag/session/placeholder); the plan
426
+ goes to stderr and the list of would-be request bodies (JSON) to stdout."""
427
+ import uuid
428
+
429
+ bodies: list[dict[str, Any]] = []
430
+ approximate = False
431
+ for row in rows:
432
+ recipient, exact = _local_recipient(row.get("recipient"))
433
+ approximate = approximate or not exact
434
+ operation = builder(
435
+ args.credit_card_id,
436
+ row["name"],
437
+ row["balance_cents"],
438
+ row["valid_to"],
439
+ recurrence=None,
440
+ recipient_resolver=lambda r=recipient: r,
441
+ token_factory=lambda: uuid.uuid4().hex[:8],
442
+ )
443
+ bodies.append(operation["body"])
444
+
445
+ accuracy = "approximate" if approximate else "exact"
446
+ _info(f"[dry-run] {len(bodies)} cards, no API calls (preview: {accuracy}).")
447
+ print(_json_out(bodies))
448
+ return EXIT_OK
449
+
450
+
451
+ def _cmd_reveal(args: argparse.Namespace) -> int:
452
+ from .cards import reveal_card
453
+
454
+ _info("WARNING: Card credentials are highly sensitive.")
455
+ _info("Do not share, log, or transmit them insecurely.")
456
+
457
+ creds = reveal_card(args.id)
458
+ json_path = getattr(args, "json_path", None)
459
+
460
+ # Security boundary: full PAN/CVC reaches stdout via NO path. stdout is
461
+ # captured by shell history, CI logs, and agent transcripts, so raw
462
+ # credentials only ever go to a 0600 file (--json-path). The global --json
463
+ # flag emits a MASKED structure; human mode masks too.
464
+ if json_path:
465
+ path = Path(json_path)
466
+ fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
467
+ try:
468
+ os.chmod(tmp_name, 0o600)
469
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
470
+ fh.write(json.dumps(creds, indent=2, sort_keys=True))
471
+ fh.write("\n")
472
+ fh.flush()
473
+ os.fsync(fh.fileno())
474
+ os.replace(tmp_name, str(path))
475
+ os.chmod(str(path), 0o600)
476
+ except Exception:
477
+ try:
478
+ os.unlink(tmp_name)
479
+ except FileNotFoundError:
480
+ pass
481
+ raise
482
+ _info(f"Credentials written to {path} (mode 0600)")
483
+ elif getattr(args, "json", False):
484
+ masked = {
485
+ "last4": creds.get("last4"),
486
+ "expires": creds.get("expires"),
487
+ "number": _mask_card_number(creds.get("number", "")),
488
+ "cvc": "****",
489
+ }
490
+ print(_json_out(masked))
491
+ else:
492
+ number = creds.get("number", "")
493
+ print(f"Card Number: {_mask_card_number(number)}")
494
+ print(f"Last 4: {creds.get('last4', 'N/A')}")
495
+ print(f"Expires: {creds.get('expires', 'N/A')}")
496
+ print("CVC: ****")
497
+ return EXIT_OK
498
+
499
+
500
+ def _cmd_update(args: argparse.Namespace) -> int:
501
+ from .cards import update_card
502
+
503
+ kwargs: dict[str, Any] = {}
504
+ if args.balance_cents is not None:
505
+ kwargs["balance_cents"] = args.balance_cents
506
+ if args.name is not None:
507
+ kwargs["name"] = args.name
508
+ if args.valid_to is not None:
509
+ kwargs["valid_to"] = args.valid_to
510
+
511
+ if not kwargs:
512
+ raise CLIInputError("no fields to update. Use --balance-cents, --name, or --valid-to.")
513
+
514
+ if getattr(args, "dry_run", False):
515
+ return _update_dry_run(args, kwargs)
516
+
517
+ card = update_card(args.id, **kwargs)
518
+ if getattr(args, "json", False):
519
+ print(_json_out(_card_to_dict(card)))
520
+ else:
521
+ print(f"Updated: {card.id} (last4={card.last4}, status={card.status.value})")
522
+ return EXIT_OK
523
+
524
+
525
+ def _update_dry_run(args: argparse.Namespace, kwargs: dict[str, Any]) -> int:
526
+ """Preview an update. The read-only GET is allowed (non-destructive) so the
527
+ merged PUT body is accurate; no mutation is performed."""
528
+ from .cards import _default_client, _update_overrides, build_update_card_operation
529
+
530
+ client = _default_client()
531
+ overrides = _update_overrides(
532
+ balance_cents=kwargs.get("balance_cents"),
533
+ name=kwargs.get("name"),
534
+ valid_to=kwargs.get("valid_to"),
535
+ recurs=None,
536
+ )
537
+ operation = build_update_card_operation(
538
+ args.id,
539
+ overrides,
540
+ fetcher=lambda: client.get(f"/virtualcards/{args.id}"),
541
+ )
542
+ _info(f"[dry-run] update {args.id} — overrides: {overrides}. No mutation made.")
543
+ print(_json_out(operation["body"]))
544
+ return EXIT_OK
545
+
546
+
547
+ def _bodyless_descriptor(card_id: str, *, action: str, reversible: bool) -> dict[str, Any]:
548
+ """Build a dry-run descriptor for a bodyless PUT (cancel/close)."""
549
+ return {
550
+ "method": "PUT",
551
+ "path": f"/virtualcards/{card_id}/{action}",
552
+ "card_id": card_id,
553
+ "reversible": reversible,
554
+ "body": None,
555
+ }
556
+
557
+
558
+ def _cmd_cancel(args: argparse.Namespace) -> int:
559
+ from .cards import cancel_card
560
+
561
+ if getattr(args, "dry_run", False):
562
+ _info(f"[dry-run] would cancel {args.id} (reversible). No API call made.")
563
+ print(_json_out(_bodyless_descriptor(args.id, action="cancel", reversible=True)))
564
+ return EXIT_OK
565
+
566
+ card = cancel_card(args.id)
567
+ if getattr(args, "json", False):
568
+ print(_json_out(_card_to_dict(card)))
569
+ else:
570
+ print(f"Cancelled: {card.id} (last4={card.last4}, status={card.status.value})")
571
+ return EXIT_OK
572
+
573
+
574
+ def _cmd_close(args: argparse.Namespace) -> int:
575
+ from .cards import close_card
576
+
577
+ if getattr(args, "dry_run", False):
578
+ _info(f"[dry-run] would close {args.id} (PERMANENT, not reversible). No API call made.")
579
+ print(_json_out(_bodyless_descriptor(args.id, action="close", reversible=False)))
580
+ return EXIT_OK
581
+
582
+ _info(f"WARNING: Closing card {args.id} is permanent and cannot be undone.")
583
+ if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
584
+ _info("Cancelled.")
585
+ return EXIT_ERROR
586
+
587
+ card = close_card(args.id)
588
+ if getattr(args, "json", False):
589
+ print(_json_out(_card_to_dict(card)))
590
+ else:
591
+ print(f"Closed: {card.id} (last4={card.last4}, status={card.status.value})")
592
+ return EXIT_OK
593
+
594
+
595
+ def _cmd_reconcile(args: argparse.Namespace) -> int:
596
+ from .cards import reconcile
597
+
598
+ result = reconcile()
599
+ if getattr(args, "json", False):
600
+ print(_json_out(result))
601
+ else:
602
+ adopted = result.get("adopted", [])
603
+ failed = result.get("failed", [])
604
+ if adopted:
605
+ print(f"Adopted {len(adopted)} card(s):")
606
+ for card_id in adopted:
607
+ print(f" {card_id}")
608
+ if failed:
609
+ print(f"Failed {len(failed)} pending row(s):")
610
+ for key in failed:
611
+ print(f" {key}")
612
+ if not adopted and not failed:
613
+ _info("No pending rows to reconcile.")
614
+ return EXIT_OK
615
+
616
+
617
+ def _cmd_status(args: argparse.Namespace) -> int:
618
+ from .client import disabled_status
619
+
620
+ status = disabled_status()
621
+ if getattr(args, "json", False):
622
+ if status is None:
623
+ print(_json_out({"disabled": False}))
624
+ else:
625
+ print(_json_out(status))
626
+ else:
627
+ if status is None:
628
+ print("Status: ENABLED")
629
+ else:
630
+ print("Status: DISABLED")
631
+ print(f"Reason: {status.get('reason', 'unknown')}")
632
+ if "timestamp" in status:
633
+ print(f"Since: {status['timestamp']}")
634
+ if "path" in status:
635
+ print(f"File: {status['path']}")
636
+ _info("\nTo re-enable: extendvcc clear-disabled --manual")
637
+ return EXIT_OK
638
+
639
+
640
+ def _cmd_clear_disabled(args: argparse.Namespace) -> int:
641
+ from .client import clear_disabled
642
+
643
+ manual = getattr(args, "manual", False)
644
+ if not manual:
645
+ raise CLIInputError(
646
+ "--manual flag is required to confirm re-enabling. Usage: extendvcc clear-disabled --manual"
647
+ )
648
+
649
+ removed = clear_disabled(manual=True)
650
+ if getattr(args, "json", False):
651
+ print(_json_out({"cleared": removed}))
652
+ else:
653
+ if removed:
654
+ _info("Kill switch cleared. Client re-enabled.")
655
+ else:
656
+ _info("No disabled-state file found. Client was already enabled.")
657
+ return EXIT_OK
658
+
659
+
660
+ # ---------------------------------------------------------------------------
661
+ # Parser construction
662
+ # ---------------------------------------------------------------------------
663
+
664
+
665
+ class _ArgumentParser(argparse.ArgumentParser):
666
+ """ArgumentParser that exits with the stable EXIT_USAGE code on bad input.
667
+
668
+ argparse already exits 2, but we route it through the constant so the usage
669
+ contract is explicit and stays correct if the value ever changes.
670
+ """
671
+
672
+ def error(self, message: str) -> Any: # noqa: D102
673
+ self.print_usage(sys.stderr)
674
+ self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
675
+
676
+
677
+ def _build_parser() -> argparse.ArgumentParser:
678
+ parser = _ArgumentParser(
679
+ prog="extendvcc",
680
+ description="Unofficial CLI for the Extend virtual card API.",
681
+ epilog=DISCLAIMER,
682
+ )
683
+ parser.add_argument("--state-dir", default=None, help="Override state directory")
684
+ parser.add_argument("--ledger", default=None, help="Override ledger file path")
685
+ parser.add_argument("--json", action="store_true", default=False, help="Machine-readable JSON output")
686
+
687
+ sub = parser.add_subparsers(dest="command")
688
+
689
+ # login
690
+ p = sub.add_parser("login", help="Authenticate with Extend (SRP + email OTP)")
691
+ p.add_argument("--email", default=None, help="Email (or EXTENDVCC_EMAIL env var)")
692
+
693
+ # accounts
694
+ sub.add_parser("accounts", help="List enrolled parent credit cards")
695
+
696
+ # enroll
697
+ p = sub.add_parser("enroll", help="Enroll a new parent credit card")
698
+ p.add_argument("--display-name", required=True, help="Display name for the card")
699
+ p.add_argument("--cardholder-name", required=True, help="Name on the card")
700
+ p.add_argument("--issuer-id", required=True, help="Issuer ID (see 'issuers' command)")
701
+ p.add_argument("--expires", required=True, help="Card expiration date (YYYY-MM-DD)")
702
+ p.add_argument("--address1", required=True, help="Billing address line 1")
703
+ p.add_argument("--address2", default="", help="Billing address line 2")
704
+ p.add_argument("--city", required=True, help="Billing city")
705
+ p.add_argument("--province", required=True, help="Billing state/province")
706
+ p.add_argument("--postal", required=True, help="Billing postal/ZIP code")
707
+ p.add_argument("--company-name", default=None, help="Company name (defaults to cardholder name)")
708
+ p.add_argument("--country", default="US", help="Country code (default: US)")
709
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
710
+
711
+ # activate
712
+ p = sub.add_parser("activate", help="Activate a verified parent credit card (PENDING -> ACTIVE)")
713
+ p.add_argument("id", help="Credit card ID")
714
+
715
+ # issuers
716
+ sub.add_parser("issuers", help="List card issuers")
717
+
718
+ # cards
719
+ p = sub.add_parser("cards", help="List virtual cards")
720
+ p.add_argument("--status", default=None, help="Filter by status (ACTIVE, CANCELLED, PENDING, etc.)")
721
+
722
+ # card
723
+ p = sub.add_parser("card", help="Get a single virtual card")
724
+ p.add_argument("id", help="Virtual card ID")
725
+
726
+ # usage
727
+ sub.add_parser("usage", help="Show active virtual card usage vs. limit")
728
+
729
+ # create
730
+ p = sub.add_parser("create", help="Create a virtual card (one-time or recurring)")
731
+ p.add_argument("--credit-card-id", required=True, help="Parent credit card ID")
732
+ p.add_argument("--name", required=True, help="Card display name")
733
+ p.add_argument("--balance-cents", required=True, type=int, help="Spending limit in cents")
734
+ p.add_argument("--recipient", default=None, help="Recipient email (defaults to account email)")
735
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
736
+ # One-time
737
+ p.add_argument("--valid-to", default=None, help="Expiration date YYYY-MM-DD (one-time card)")
738
+ # Recurring (mutually exclusive with --valid-to conceptually)
739
+ p.add_argument("--period", default=None, help="Recurrence period: DAILY, WEEKLY, or MONTHLY")
740
+ p.add_argument("--interval", type=int, default=1, help="Reset every N periods (default: 1)")
741
+ p.add_argument("--by-month-day", type=int, default=None, help="Day of month (1-31, MONTHLY only)")
742
+ p.add_argument("--by-week-day", type=int, default=None, help="Day of week (0-6, WEEKLY only)")
743
+ p.add_argument("--terminator", default=None, help="NONE, DATE, or COUNT")
744
+ p.add_argument("--until", default=None, help="End date YYYY-MM-DD (DATE terminator)")
745
+ p.add_argument("--count", type=int, default=None, help="Number of resets (COUNT terminator)")
746
+ p.add_argument("--dry-run", action="store_true", help="Preview the request body (no API call)")
747
+
748
+ # bulk
749
+ p = sub.add_parser("bulk", help="Bulk-create virtual cards from a CSV file")
750
+ p.add_argument("file", help="CSV file (columns: name, balance_cents, valid_to, [recipient])")
751
+ p.add_argument("--credit-card-id", required=True, help="Parent credit card ID")
752
+ p.add_argument("--delay", type=float, default=2.0, help="Mean delay between cards in seconds (default: 2.0)")
753
+ p.add_argument("--jitter", type=float, default=0.75, help="Delay jitter std-dev in seconds (default: 0.75)")
754
+ p.add_argument("--min-delay", type=float, default=0.5, help="Minimum delay between cards in seconds (default: 0.5)")
755
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
756
+ p.add_argument("--dry-run", action="store_true", help="Preview the request bodies (no API calls)")
757
+
758
+ # reveal
759
+ p = sub.add_parser("reveal", help="Reveal live card credentials (PAN, CVC, expiry)")
760
+ p.add_argument("id", help="Virtual card ID")
761
+ p.add_argument(
762
+ "--json-path",
763
+ default=None,
764
+ metavar="PATH",
765
+ help="Save full credentials to file (0600 perms) instead of printing masked",
766
+ )
767
+
768
+ # update
769
+ p = sub.add_parser("update", help="Update a virtual card (read-modify-write)")
770
+ p.add_argument("id", help="Virtual card ID")
771
+ p.add_argument("--balance-cents", type=int, default=None, help="New spending limit in cents")
772
+ p.add_argument("--name", default=None, help="New display name")
773
+ p.add_argument("--valid-to", default=None, help="New expiration date YYYY-MM-DD")
774
+ p.add_argument("--dry-run", action="store_true", help="Preview the merged PUT body (read-only GET, no mutation)")
775
+
776
+ # cancel
777
+ p = sub.add_parser("cancel", help="Cancel a virtual card (reversible)")
778
+ p.add_argument("id", help="Virtual card ID")
779
+ p.add_argument("--dry-run", action="store_true", help="Preview the operation descriptor (no API call)")
780
+
781
+ # close
782
+ p = sub.add_parser("close", help="Close a virtual card permanently")
783
+ p.add_argument("id", help="Virtual card ID")
784
+ p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
785
+ p.add_argument("--dry-run", action="store_true", help="Preview the operation descriptor (no API call)")
786
+
787
+ # reconcile
788
+ sub.add_parser("reconcile", help="Resolve pending ledger rows against remote cards")
789
+
790
+ # status
791
+ sub.add_parser("status", help="Show kill-switch state")
792
+
793
+ # clear-disabled
794
+ p = sub.add_parser("clear-disabled", help="Re-enable client after kill-switch trip")
795
+ p.add_argument("--manual", action="store_true", help="Confirm manual override (required)")
796
+
797
+ return parser
798
+
799
+
800
+ # ---------------------------------------------------------------------------
801
+ # Entry point
802
+ # ---------------------------------------------------------------------------
803
+
804
+ _COMMANDS: dict[str, Any] = {
805
+ "login": _cmd_login,
806
+ "accounts": _cmd_accounts,
807
+ "enroll": _cmd_enroll,
808
+ "activate": _cmd_activate,
809
+ "issuers": _cmd_issuers,
810
+ "cards": _cmd_cards,
811
+ "card": _cmd_card,
812
+ "usage": _cmd_usage,
813
+ "create": _cmd_create,
814
+ "bulk": _cmd_bulk,
815
+ "reveal": _cmd_reveal,
816
+ "update": _cmd_update,
817
+ "cancel": _cmd_cancel,
818
+ "close": _cmd_close,
819
+ "reconcile": _cmd_reconcile,
820
+ "status": _cmd_status,
821
+ "clear-disabled": _cmd_clear_disabled,
822
+ }
823
+
824
+
825
+ def main(argv: list[str] | None = None) -> int:
826
+ """CLI entry point. Returns exit code."""
827
+ parser = _build_parser()
828
+ args = parser.parse_args(argv)
829
+
830
+ # No subcommand: help to stderr (keeps stdout JSON-only under --json), exit 2.
831
+ if not args.command:
832
+ parser.print_help(sys.stderr)
833
+ return EXIT_USAGE
834
+
835
+ # Configure paths before dispatching
836
+ from . import _paths
837
+
838
+ path_kwargs: dict[str, Any] = {}
839
+ if args.state_dir:
840
+ path_kwargs["state_dir"] = args.state_dir
841
+ if args.ledger:
842
+ path_kwargs["ledger_path"] = args.ledger
843
+ if path_kwargs:
844
+ _paths.configure(**path_kwargs)
845
+
846
+ handler = _COMMANDS.get(args.command)
847
+ if handler is None:
848
+ parser.print_help(sys.stderr)
849
+ return EXIT_USAGE
850
+
851
+ from .auth import PayWithExtendAuthError
852
+ from .client import PayWithExtendAPIError, PayWithExtendDisabled, PayWithExtendError
853
+
854
+ # Catch order is most-specific-first. Note auth errors are RuntimeError, NOT
855
+ # PayWithExtendError, so they are caught on their own branch.
856
+ try:
857
+ return handler(args)
858
+ except PayWithExtendDisabled as exc: # covers AccountRiskDetected
859
+ print(f"Error: {exc}", file=sys.stderr)
860
+ print("Hint: run 'extendvcc clear-disabled --manual' to re-enable.", file=sys.stderr)
861
+ return EXIT_DISABLED
862
+ except PayWithExtendAuthError as exc: # covers SessionNotFound, OTPRequired, UnexpectedChallenge
863
+ print(f"Error: {exc}", file=sys.stderr)
864
+ return EXIT_AUTH_REQUIRED
865
+ except PayWithExtendAPIError as exc:
866
+ print(f"Error: {exc}", file=sys.stderr)
867
+ return EXIT_API_ERROR
868
+ except PayWithExtendError as exc:
869
+ print(f"Error: {exc}", file=sys.stderr)
870
+ return EXIT_ERROR
871
+ except KeyboardInterrupt:
872
+ print("\nInterrupted.", file=sys.stderr)
873
+ return 130
874
+ except CLIInputError as exc: # CLI-owned validation -> usage error
875
+ print(f"Error: {exc}", file=sys.stderr)
876
+ return EXIT_USAGE
877
+ except ValueError as exc: # library-internal ValueError -> generic error, NOT usage
878
+ print(f"Error: {exc}", file=sys.stderr)
879
+ return EXIT_ERROR
880
+
881
+
882
+ if __name__ == "__main__":
883
+ raise SystemExit(main())