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/cards.py ADDED
@@ -0,0 +1,761 @@
1
+ """PayWithExtend card operations — reads, mutations, and account context helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import random
7
+ import time
8
+ import uuid
9
+ from collections.abc import Callable, Sequence
10
+ from datetime import date, datetime
11
+ from typing import Any
12
+
13
+ from . import auth, ledger
14
+ from .client import PayWithExtendAPIError, PayWithExtendError
15
+ from .models import CardStatus, CreditCard, Issuer, Recurrence, VirtualCard
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Fields that may be included in a PUT /virtualcards/{id} body (allowlist).
20
+ UPDATE_PAYLOAD_FIELDS = (
21
+ "creditCardId",
22
+ "displayName",
23
+ "expenseDetails",
24
+ "balanceCents",
25
+ "recurs",
26
+ "receiptAttachmentIds",
27
+ "validTo",
28
+ "currency",
29
+ "receiptRulesExempt",
30
+ "lowLimitAlert",
31
+ )
32
+
33
+ _PAGE_SIZE = 100
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Private parse helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _parse_date(value: Any) -> date | None:
42
+ if not value:
43
+ return None
44
+ try:
45
+ return datetime.fromisoformat(str(value)).date()
46
+ except (ValueError, TypeError):
47
+ return None
48
+
49
+
50
+ def _parse_datetime(value: Any) -> datetime | None:
51
+ if not value:
52
+ return None
53
+ try:
54
+ return datetime.fromisoformat(str(value))
55
+ except (ValueError, TypeError):
56
+ return None
57
+
58
+
59
+ def _require(data: Any, key: str, context: str) -> Any:
60
+ """Return ``data[key]`` or raise a typed error naming the missing field.
61
+
62
+ Turns an opaque ``KeyError``/``TypeError`` from an unexpected Extend
63
+ response shape into an actionable ``PayWithExtendError``.
64
+ """
65
+ try:
66
+ return data[key]
67
+ except (KeyError, TypeError) as exc:
68
+ raise PayWithExtendError(f"unexpected Extend {context}: missing field {key!r}") from exc
69
+
70
+
71
+ def _map_virtual_card(card: dict[str, Any]) -> VirtualCard:
72
+ try:
73
+ return VirtualCard(
74
+ id=card["id"],
75
+ credit_card_id=card["creditCardId"],
76
+ name=card["displayName"],
77
+ last4=card["last4"],
78
+ status=CardStatus(card["status"]),
79
+ balance_cents=card["balanceCents"],
80
+ valid_from=_parse_date(card.get("validFrom")),
81
+ valid_to=_parse_date(card.get("validTo")),
82
+ notes=None,
83
+ created_at=_parse_datetime(card.get("createdAt")),
84
+ )
85
+ except (KeyError, TypeError) as exc:
86
+ raise PayWithExtendError(f"unexpected virtual card shape: missing field {exc}") from exc
87
+
88
+
89
+ def _default_client() -> Any:
90
+ from .client import PayWithExtendClient
91
+
92
+ return PayWithExtendClient()
93
+
94
+
95
+ def _format_date(value: Any) -> str:
96
+ """Format a date/datetime/str as YYYY-MM-DD."""
97
+ if isinstance(value, datetime):
98
+ return value.date().strftime("%Y-%m-%d")
99
+ if isinstance(value, date):
100
+ return value.strftime("%Y-%m-%d")
101
+ if isinstance(value, str):
102
+ return value
103
+ raise TypeError(f"_format_date: unsupported type {type(value).__name__}")
104
+
105
+
106
+ def _card_to_ledger_dict(card: VirtualCard) -> dict[str, Any]:
107
+ """Convert a VirtualCard to a JSON-safe dict suitable for the ledger.
108
+
109
+ The ledger's JSON serializer cannot handle date/datetime objects so we
110
+ convert them to ISO strings here. No PAN/CVC data is ever present on a
111
+ VirtualCard — mutations do not return secrets.
112
+ """
113
+ return {
114
+ "id": card.id,
115
+ "credit_card_id": card.credit_card_id,
116
+ "name": card.name,
117
+ "last4": card.last4,
118
+ "status": str(card.status),
119
+ "balance_cents": card.balance_cents,
120
+ "valid_from": card.valid_from.isoformat() if card.valid_from is not None else None,
121
+ "valid_to": card.valid_to.isoformat() if card.valid_to is not None else None,
122
+ "notes": card.notes,
123
+ "created_at": card.created_at.isoformat() if card.created_at is not None else None,
124
+ }
125
+
126
+
127
+ def _virtual_card_success(resp: Any) -> tuple[Any, dict[str, Any]]:
128
+ """Default ``on_success``: map a virtual-card response and ledger the card row."""
129
+ card = _map_virtual_card(_require(resp, "virtualCard", "mutation response"))
130
+ return card, {"card_record": _card_to_ledger_dict(card)}
131
+
132
+
133
+ def _ledger_flow(intent: str, key: str, dispatch: Any, on_success: Any = None) -> Any:
134
+ """Record pending, dispatch, resolve. Leave pending on 5xx/network failure.
135
+
136
+ ``on_success(resp)`` returns ``(return_value, resolve_fields)``. It defaults
137
+ to the virtual-card mapping used by create/update/cancel/close; enrollment
138
+ passes its own mapper. A 4xx marks the pending row failed; any other failure
139
+ (kill switch, timeout, network) leaves it pending as local evidence.
140
+ """
141
+ if on_success is None:
142
+ on_success = _virtual_card_success
143
+ ledger.record_pending(intent, key)
144
+ try:
145
+ resp = dispatch()
146
+ except PayWithExtendAPIError as exc:
147
+ if 400 <= exc.status_code < 500:
148
+ ledger.resolve_pending(key, "failed", error=str(exc))
149
+ raise
150
+ result, resolve_fields = on_success(resp)
151
+ ledger.resolve_pending(key, "confirmed", **resolve_fields)
152
+ return result
153
+
154
+
155
+ def account_context() -> dict[str, Any]:
156
+ """Return ``{"email": str, "org_id": str | None}`` from the saved session.
157
+
158
+ Deliberately avoids ``read_credentials``, ``authenticate``, and
159
+ ``ensure_valid_token`` so the account password is never touched. This refreshes
160
+ unconditionally via the refresh token (which never reads the password); that is
161
+ an intentional safety trade-off, not an oversight — see the password-free tests.
162
+
163
+ Raises:
164
+ auth.SessionNotFound: if no session (or no email) exists.
165
+ client.PayWithExtendDisabled: if the kill switch is set (propagated
166
+ from ``refresh_tokens`` / ``fetch_current_user``).
167
+ """
168
+ session = auth.load_session()
169
+ if not session or not session.get("email"):
170
+ raise auth.SessionNotFound("PayWithExtend setup required — run setup()")
171
+
172
+ refreshed = auth.refresh_tokens(session)
173
+ email: str = refreshed.get("email") or session["email"]
174
+ access_token: str = refreshed["access_token"]
175
+
176
+ org_id: str | None = refreshed.get("org_id") or session.get("org_id")
177
+ if not org_id:
178
+ payload, _ = auth.fetch_current_user(access_token)
179
+ org_id = auth.extract_org_id(payload)
180
+ if org_id:
181
+ updated = {**refreshed, "org_id": org_id}
182
+ auth.save_session(updated)
183
+
184
+ return {"email": email, "org_id": org_id}
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Read functions
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def list_credit_cards(*, client: Any = None) -> list[CreditCard]:
193
+ """Return all credit cards on the account. GET /creditcards."""
194
+ c = client or _default_client()
195
+ data = c.get("/creditcards")
196
+ try:
197
+ return [
198
+ CreditCard(
199
+ id=card["id"],
200
+ last4=card["last4"],
201
+ status=CardStatus(card["status"]),
202
+ display_name=card["displayName"],
203
+ )
204
+ for card in data.get("creditCards", [])
205
+ ]
206
+ except (KeyError, TypeError) as exc:
207
+ raise PayWithExtendError(f"unexpected credit card shape: missing field {exc}") from exc
208
+
209
+
210
+ def list_issuers(*, client: Any = None) -> list[Issuer]:
211
+ """Return all issuers. GET /issuers."""
212
+ c = client or _default_client()
213
+ data = c.get("/issuers")
214
+ try:
215
+ return [Issuer(id=issuer["id"], name=issuer["name"], code=issuer["code"]) for issuer in data.get("issuers", [])]
216
+ except (KeyError, TypeError) as exc:
217
+ raise PayWithExtendError(f"unexpected issuer shape: missing field {exc}") from exc
218
+
219
+
220
+ def list_cards(
221
+ *,
222
+ status: CardStatus | str | None = None,
223
+ credit_card_id: str | None = None,
224
+ client: Any = None,
225
+ ) -> list[VirtualCard]:
226
+ """Return all virtual cards, with optional status/credit_card_id filters.
227
+
228
+ Uses page-based pagination (0-indexed ``page`` param). Safe to call with
229
+ no arguments so ``ledger.sync(fetcher=cards.list_cards)`` works.
230
+ """
231
+ c = client or _default_client()
232
+ params: dict[str, Any] = {"count": _PAGE_SIZE}
233
+ if credit_card_id:
234
+ params["creditCardId"] = credit_card_id
235
+ if status is not None:
236
+ params["statuses"] = status.value if hasattr(status, "value") else status
237
+
238
+ results: list[VirtualCard] = []
239
+ page = 0
240
+
241
+ while True:
242
+ data = c.get("/virtualcards", params={**params, "page": page})
243
+ for card in data.get("virtualCards", []):
244
+ results.append(_map_virtual_card(card))
245
+ num_pages = (data.get("pagination") or {}).get("numberOfPages")
246
+ if num_pages is None or page + 1 >= num_pages:
247
+ break
248
+ page += 1
249
+
250
+ return results
251
+
252
+
253
+ def get_card(card_id: str, *, client: Any = None) -> VirtualCard:
254
+ """Return a single virtual card by id. GET /virtualcards/{id}."""
255
+ c = client or _default_client()
256
+ data = c.get(f"/virtualcards/{card_id}")
257
+ return _map_virtual_card(_require(data, "virtualCard", "get_card response"))
258
+
259
+
260
+ def usage(*, client: Any = None) -> dict[str, int]:
261
+ """Return active virtual card usage against the account limit.
262
+
263
+ Returns ``{"used": int, "remaining": int, "limit": int}``.
264
+
265
+ Raises:
266
+ ValueError: if org_id cannot be resolved from the session.
267
+ """
268
+ ctx = account_context()
269
+ org_id = ctx.get("org_id")
270
+ if not org_id:
271
+ raise ValueError("org_id is not available in the session — run account_context() setup first")
272
+
273
+ c = client or _default_client()
274
+ data = c.get(f"/saas/{org_id}/usages")
275
+ try:
276
+ feature = data["features"]["ACTIVE_VIRTUAL_CARD_LIMIT"]
277
+ entitlement: int = feature["entitlement"]
278
+ used: int = feature["usage"]
279
+ except (KeyError, TypeError) as exc:
280
+ raise PayWithExtendError(f"unexpected usage response shape: missing field {exc}") from exc
281
+ return {"used": used, "remaining": entitlement - used, "limit": entitlement}
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Mutation functions
286
+ # ---------------------------------------------------------------------------
287
+
288
+ _RECURRENCE_PERIODS = ("DAILY", "WEEKLY", "MONTHLY")
289
+ _RECURRENCE_TERMINATORS = ("NONE", "DATE", "COUNT")
290
+
291
+
292
+ def _recurrence_payload(rec: Recurrence, balance_cents: int) -> dict[str, Any]:
293
+ """Build Extend's ``recurrence`` object from a validated ``Recurrence``.
294
+
295
+ Shapes match captured live requests: DAILY (no day field), WEEKLY
296
+ (``byWeekDay``), MONTHLY (``byMonthDay``); terminators NONE, DATE (``until``),
297
+ COUNT (``count``). Invalid combinations raise before any API call.
298
+ """
299
+ if rec.period not in _RECURRENCE_PERIODS:
300
+ raise ValueError(f"recurrence period must be one of {_RECURRENCE_PERIODS}, got {rec.period!r}")
301
+ if rec.terminator not in _RECURRENCE_TERMINATORS:
302
+ raise ValueError(f"recurrence terminator must be one of {_RECURRENCE_TERMINATORS}, got {rec.terminator!r}")
303
+ if rec.interval < 1:
304
+ raise ValueError("recurrence interval must be >= 1")
305
+ payload: dict[str, Any] = {
306
+ "balanceCents": balance_cents,
307
+ "period": rec.period,
308
+ "interval": rec.interval,
309
+ "terminator": rec.terminator,
310
+ }
311
+ if rec.period == "MONTHLY":
312
+ if rec.by_month_day is None or not 1 <= rec.by_month_day <= 31:
313
+ raise ValueError("MONTHLY recurrence requires by_month_day in 1..31")
314
+ payload["byMonthDay"] = rec.by_month_day
315
+ elif rec.period == "WEEKLY":
316
+ if rec.by_week_day is None or not 0 <= rec.by_week_day <= 6:
317
+ raise ValueError("WEEKLY recurrence requires by_week_day in 0..6")
318
+ payload["byWeekDay"] = rec.by_week_day
319
+
320
+ if rec.terminator == "DATE":
321
+ if not rec.until:
322
+ raise ValueError("DATE terminator requires until ('YYYY-MM-DD')")
323
+ try:
324
+ date.fromisoformat(rec.until)
325
+ except (TypeError, ValueError) as exc:
326
+ raise ValueError(f"DATE terminator until is invalid: {exc}") from exc
327
+ payload["until"] = rec.until
328
+ elif rec.terminator == "COUNT":
329
+ if rec.count is None or rec.count < 1:
330
+ raise ValueError("COUNT terminator requires count >= 1")
331
+ payload["count"] = rec.count
332
+ return payload
333
+
334
+
335
+ def build_create_card_operation(
336
+ credit_card_id: str,
337
+ name: str,
338
+ balance_cents: int,
339
+ valid_to: Any = None,
340
+ *,
341
+ recurrence: Recurrence | None = None,
342
+ recipient_resolver: Callable[[], str],
343
+ token_factory: Callable[[], str],
344
+ ) -> dict[str, Any]:
345
+ """Shape a ``POST /virtualcards`` operation without dispatching it.
346
+
347
+ Returns an operation descriptor used by both the real ``create_card`` path
348
+ and the CLI dry-run path so the request body has a single source of truth.
349
+ The UUID correlation suffix is baked into the returned descriptor (not
350
+ regenerated per call) so a dry-run preview matches what a real run would send.
351
+
352
+ Args:
353
+ recipient_resolver: callable returning the recipient email. Real runs pass
354
+ an ``account_context()`` lookup; dry-run passes a network-free resolver.
355
+ token_factory: callable returning the short correlation token.
356
+ """
357
+ if (valid_to is None) == (recurrence is None):
358
+ raise ValueError("create_card: provide exactly one of valid_to or recurrence")
359
+ correlation_name = f"{name} [{token_factory()}]"
360
+ body: dict[str, Any] = {
361
+ "creditCardId": credit_card_id,
362
+ "displayName": correlation_name,
363
+ "expenseDetails": [],
364
+ "balanceCents": balance_cents,
365
+ "currency": "USD",
366
+ "receiptAttachmentIds": [],
367
+ "lowLimitAlert": {"alertEnabled": False, "amountThresholdCents": None},
368
+ "recipient": recipient_resolver(),
369
+ }
370
+ if recurrence is not None:
371
+ body["recurs"] = True
372
+ body["recurrence"] = _recurrence_payload(recurrence, balance_cents)
373
+ else:
374
+ body["validTo"] = _format_date(valid_to)
375
+ return {
376
+ "method": "POST",
377
+ "path": "/virtualcards",
378
+ "body": body,
379
+ "correlation_key": correlation_name,
380
+ "preview_accuracy": "exact",
381
+ }
382
+
383
+
384
+ def create_card(
385
+ credit_card_id: str,
386
+ name: str,
387
+ balance_cents: int,
388
+ valid_to: Any = None,
389
+ *,
390
+ recipient: str | None = None,
391
+ recurrence: Recurrence | None = None,
392
+ client: Any = None,
393
+ ) -> VirtualCard:
394
+ """Create a virtual card and record it in the ledger.
395
+
396
+ Provide exactly one of ``valid_to`` (one-time card that expires on a date) or
397
+ ``recurrence`` (limit auto-resets each period). The ``displayName`` sent to
398
+ Extend is suffixed with a short UUID token so that a timed-out create can be
399
+ matched by name during ``reconcile()``.
400
+ """
401
+ c = client or _default_client()
402
+ operation = build_create_card_operation(
403
+ credit_card_id,
404
+ name,
405
+ balance_cents,
406
+ valid_to,
407
+ recurrence=recurrence,
408
+ recipient_resolver=lambda: recipient or account_context()["email"],
409
+ token_factory=lambda: uuid.uuid4().hex[:8],
410
+ )
411
+ correlation_name = operation["correlation_key"]
412
+ body = operation["body"]
413
+ return _ledger_flow("create", correlation_name, lambda: c.post("/virtualcards", json_body=body))
414
+
415
+
416
+ _BULK_REQUIRED_FIELDS = ("name", "balance_cents", "valid_to")
417
+
418
+
419
+ def _validate_bulk_row(index: int, row: dict[str, Any]) -> None:
420
+ """Validate a bulk row's shape before any card is created.
421
+
422
+ Checks presence *and* well-formedness of the structured fields so a malformed
423
+ row fails the whole batch up front, rather than half-creating the set when a
424
+ bad value only blows up inside ``create_card`` mid-loop.
425
+ """
426
+ missing = [f for f in _BULK_REQUIRED_FIELDS if row.get(f) is None]
427
+ if missing:
428
+ raise ValueError(f"create_cards_bulk: row {index} missing required field(s): {missing}")
429
+ if not isinstance(row["name"], str) or not row["name"].strip():
430
+ raise ValueError(f"create_cards_bulk: row {index} 'name' must be a non-empty string")
431
+ if not isinstance(row["balance_cents"], int) or isinstance(row["balance_cents"], bool):
432
+ raise ValueError(f"create_cards_bulk: row {index} 'balance_cents' must be an int")
433
+ try:
434
+ if isinstance(row["valid_to"], str):
435
+ date.fromisoformat(row["valid_to"])
436
+ else:
437
+ _format_date(row["valid_to"])
438
+ except (TypeError, ValueError) as exc:
439
+ raise ValueError(f"create_cards_bulk: row {index} 'valid_to' is invalid: {exc}") from exc
440
+
441
+
442
+ def create_cards_bulk(
443
+ credit_card_id: str,
444
+ rows: Sequence[dict[str, Any]],
445
+ *,
446
+ delay_seconds: float = 2.0,
447
+ jitter_seconds: float = 0.75,
448
+ min_delay_seconds: float = 0.5,
449
+ rng: Callable[[float, float], float] = random.gauss,
450
+ sleeper: Callable[[float], None] = time.sleep,
451
+ client: Any = None,
452
+ ) -> list[VirtualCard]:
453
+ """Create many virtual cards by looping ``create_card`` with a paced delay.
454
+
455
+ This deliberately does NOT use Extend's native bulk-upload endpoint (which is
456
+ async and returns no card ids — see the plan's 4.1). Each row goes through
457
+ ``create_card`` so every card is returned synchronously and ledgered.
458
+
459
+ Pacing: between cards (never before the first, never after the last) sleep a
460
+ Gaussian delay ``rng(delay_seconds, jitter_seconds)`` clamped to
461
+ ``>= min_delay_seconds`` so the left tail of the distribution can never burst.
462
+ Set ``delay_seconds=0`` to disable pacing entirely (used by tests).
463
+
464
+ Args:
465
+ rows: dicts with required ``name``, ``balance_cents``, ``valid_to`` and an
466
+ optional ``recipient``. Every row is validated before any card is
467
+ created, so a malformed row fails the batch before it touches Extend.
468
+
469
+ Fail-fast: if a ``create_card`` raises, the exception propagates immediately;
470
+ cards created before it remain in the ledger as durable evidence.
471
+ """
472
+ c = client or _default_client()
473
+
474
+ # Pre-validate the whole batch so a bad row doesn't half-create the set.
475
+ for index, row in enumerate(rows):
476
+ _validate_bulk_row(index, row)
477
+
478
+ created: list[VirtualCard] = []
479
+ for index, row in enumerate(rows):
480
+ if index > 0 and delay_seconds > 0:
481
+ sleeper(max(min_delay_seconds, rng(delay_seconds, jitter_seconds)))
482
+ created.append(
483
+ create_card(
484
+ credit_card_id,
485
+ row["name"],
486
+ row["balance_cents"],
487
+ row["valid_to"],
488
+ recipient=row.get("recipient"),
489
+ client=c,
490
+ )
491
+ )
492
+ return created
493
+
494
+
495
+ def build_update_card_operation(
496
+ card_id: str,
497
+ overrides: dict[str, Any],
498
+ *,
499
+ fetcher: Callable[[], Any],
500
+ ) -> dict[str, Any]:
501
+ """Shape a ``PUT /virtualcards/{id}`` operation via read-modify-write.
502
+
503
+ ``fetcher`` performs the read-only GET of the current card; its result is
504
+ projected to the update allowlist, non-allowlist fields are warned about and
505
+ dropped, then ``overrides`` are applied. Shared by ``update_card`` and the
506
+ CLI dry-run path so the PUT body is shaped identically in both.
507
+ """
508
+ raw = _require(fetcher(), "virtualCard", "update_card GET response")
509
+
510
+ # Project to allowlist.
511
+ payload: dict[str, Any] = {k: raw[k] for k in UPDATE_PAYLOAD_FIELDS if k in raw}
512
+
513
+ # Warn about dropped non-allowlist fields.
514
+ dropped = [k for k in raw if k not in UPDATE_PAYLOAD_FIELDS]
515
+ if dropped:
516
+ logger.warning(
517
+ "update_card dropping non-allowlist GET fields for %s: %s",
518
+ card_id,
519
+ sorted(dropped),
520
+ )
521
+
522
+ # Apply overrides.
523
+ payload.update(overrides)
524
+
525
+ return {
526
+ "method": "PUT",
527
+ "path": f"/virtualcards/{card_id}",
528
+ "body": payload,
529
+ "preview_accuracy": "exact",
530
+ }
531
+
532
+
533
+ def _update_overrides(
534
+ *,
535
+ balance_cents: int | None,
536
+ name: str | None,
537
+ valid_to: Any,
538
+ recurs: bool | None,
539
+ ) -> dict[str, Any]:
540
+ """Translate public update kwargs to the API field overrides (non-None only)."""
541
+ overrides: dict[str, Any] = {}
542
+ if name is not None:
543
+ overrides["displayName"] = name
544
+ if balance_cents is not None:
545
+ overrides["balanceCents"] = balance_cents
546
+ if valid_to is not None:
547
+ overrides["validTo"] = _format_date(valid_to)
548
+ if recurs is not None:
549
+ overrides["recurs"] = recurs
550
+ return overrides
551
+
552
+
553
+ def update_card(
554
+ card_id: str,
555
+ *,
556
+ balance_cents: int | None = None,
557
+ name: str | None = None,
558
+ valid_to: Any = None,
559
+ recurs: bool | None = None,
560
+ client: Any = None,
561
+ ) -> VirtualCard:
562
+ """Update a virtual card using read-modify-write against the allowlist.
563
+
564
+ Only the fields passed as non-None kwargs are overridden. All other
565
+ allowlist fields from the current remote state are preserved.
566
+ """
567
+ c = client or _default_client()
568
+ overrides = _update_overrides(
569
+ balance_cents=balance_cents,
570
+ name=name,
571
+ valid_to=valid_to,
572
+ recurs=recurs,
573
+ )
574
+ operation = build_update_card_operation(
575
+ card_id,
576
+ overrides,
577
+ fetcher=lambda: c.get(f"/virtualcards/{card_id}"),
578
+ )
579
+ payload = operation["body"]
580
+
581
+ key = f"update:{card_id}"
582
+ return _ledger_flow("update", key, lambda: c.put(f"/virtualcards/{card_id}", json_body=payload))
583
+
584
+
585
+ def cancel_card(card_id: str, *, client: Any = None) -> VirtualCard:
586
+ """Cancel a virtual card (reversible). PUT /virtualcards/{id}/cancel."""
587
+ c = client or _default_client()
588
+ key = f"cancel:{card_id}"
589
+ return _ledger_flow("cancel", key, lambda: c.put(f"/virtualcards/{card_id}/cancel"))
590
+
591
+
592
+ def close_card(card_id: str, *, client: Any = None) -> VirtualCard:
593
+ """Close a virtual card permanently. PUT /virtualcards/{id}/close."""
594
+ c = client or _default_client()
595
+ key = f"close:{card_id}"
596
+ return _ledger_flow("close", key, lambda: c.put(f"/virtualcards/{card_id}/close"))
597
+
598
+
599
+ def reconcile(*, client: Any = None) -> dict[str, list[str]]:
600
+ """Resolve stale pending create rows by matching against remote cards by name.
601
+
602
+ For each pending create row, if a remote card's name matches the correlation
603
+ key (the unique suffixed displayName), the row is confirmed. Otherwise it is
604
+ marked failed so a retry is safe.
605
+
606
+ Returns ``{"adopted": [card_ids], "failed": [correlation_keys]}``.
607
+ """
608
+ c = client or _default_client()
609
+ pendings = ledger.list_pending(intent="create")
610
+ remote = list_cards(client=c)
611
+ remote_by_name = {card.name: card for card in remote}
612
+
613
+ adopted: list[str] = []
614
+ failed: list[str] = []
615
+
616
+ for row in pendings:
617
+ key = row["correlation_key"]
618
+ remote_card = remote_by_name.get(key)
619
+ if remote_card is not None:
620
+ ledger.resolve_pending(key, "confirmed", card_record=_card_to_ledger_dict(remote_card))
621
+ adopted.append(remote_card.id)
622
+ else:
623
+ ledger.resolve_pending(key, "failed", error="reconcile: no remote card matched")
624
+ failed.append(key)
625
+
626
+ return {"adopted": adopted, "failed": failed}
627
+
628
+
629
+ def _parse_credit_card(
630
+ resp: Any,
631
+ context: str,
632
+ *,
633
+ fallback: CreditCard | None = None,
634
+ fallback_status: CardStatus | None = None,
635
+ ) -> CreditCard:
636
+ """Build a CreditCard from a response that may or may not wrap it in ``creditCard``.
637
+
638
+ If the response lacks the expected fields but a ``fallback`` card + status are
639
+ given (e.g. a 200 with an unexpected body after a known state transition),
640
+ return the fallback card with the new status rather than failing.
641
+ """
642
+ data = resp.get("creditCard", resp) if isinstance(resp, dict) else None
643
+ if isinstance(data, dict) and {"id", "last4", "status", "displayName"} <= data.keys():
644
+ return CreditCard(
645
+ id=data["id"],
646
+ last4=data["last4"],
647
+ status=CardStatus(data["status"]),
648
+ display_name=data["displayName"],
649
+ )
650
+ if fallback is not None and fallback_status is not None:
651
+ return CreditCard(
652
+ id=fallback.id,
653
+ last4=fallback.last4,
654
+ status=fallback_status,
655
+ display_name=fallback.display_name,
656
+ )
657
+ raise PayWithExtendError(f"unexpected {context} shape")
658
+
659
+
660
+ def enroll_credit_card(
661
+ display_name: str,
662
+ card_number: str,
663
+ expires: Any,
664
+ cvc: str,
665
+ cardholder_name: str,
666
+ issuer_id: str,
667
+ address: dict[str, Any],
668
+ *,
669
+ company_name: str | None = None,
670
+ country: str = "US",
671
+ client: Any = None,
672
+ ) -> CreditCard:
673
+ """Enroll a parent credit card and start its verification (steps 1+2).
674
+
675
+ Step 1: ``POST /creditcardsv2`` (vault host) registers the card — it lands in
676
+ NOT_APPLICABLE. Step 2: ``PUT /creditcardsv2/{id}/virtual`` (api host) enables
677
+ virtual cards, advancing it to PENDING and triggering the issuer's
678
+ cardholder-verification email. Returns the card in PENDING. After the
679
+ cardholder verifies by email, call ``activate_credit_card`` to reach ACTIVE.
680
+
681
+ ``card_number`` (PAN) and ``cvc`` are sent only in the HTTPS request body
682
+ and are never logged or written to the ledger.
683
+
684
+ Raises:
685
+ ValueError: if org_id cannot be resolved from the session.
686
+ """
687
+ from .client import vault_client
688
+
689
+ ctx = account_context()
690
+ org_id = ctx.get("org_id")
691
+ if not org_id:
692
+ raise ValueError("org_id is not available in the session — run account_context() setup first")
693
+
694
+ body = {
695
+ "displayName": display_name,
696
+ "country": country,
697
+ "companyName": company_name or cardholder_name,
698
+ "cardholderName": cardholder_name,
699
+ "address1": address["address1"],
700
+ "address2": address.get("address2", ""),
701
+ "city": address["city"],
702
+ "postal": address["postal"],
703
+ "province": address["province"],
704
+ "cvc": cvc,
705
+ "issuerId": issuer_id,
706
+ "issuerFields": {"cardNumber": card_number, "expires": _format_date(expires)},
707
+ "organizationId": org_id,
708
+ }
709
+
710
+ vault_c = client or vault_client() # step 1 (POST) lives on the vault host
711
+ api_c = client or _default_client() # step 2 (PUT .../virtual) lives on the api host
712
+ key = f"enroll:{display_name} [{uuid.uuid4().hex[:8]}]"
713
+
714
+ def _on_success(resp: Any) -> tuple[CreditCard, dict[str, Any]]:
715
+ credit_card = _parse_credit_card(resp, "enroll response")
716
+ return credit_card, {"credit_card_id": credit_card.id}
717
+
718
+ # Step 1 — POST the card details. The card lands in NOT_APPLICABLE (Inactive).
719
+ enrolled = _ledger_flow(
720
+ "enroll", key, lambda: vault_c.post("/creditcardsv2", json_body=body), on_success=_on_success
721
+ )
722
+ # Step 2 — enable virtual cards. This advances NOT_APPLICABLE -> PENDING and
723
+ # triggers the issuer's cardholder-verification email. Skipping it leaves the
724
+ # card stuck Inactive with no email ever sent. Once the cardholder verifies,
725
+ # call activate_credit_card() to pull PENDING -> ACTIVE.
726
+ resp = api_c.put(f"/creditcardsv2/{enrolled.id}/virtual", json_body={})
727
+ return _parse_credit_card(resp, "enable-virtual response", fallback=enrolled, fallback_status=CardStatus.PENDING)
728
+
729
+
730
+ def activate_credit_card(credit_card_id: str, *, client: Any = None) -> CreditCard:
731
+ """Step 3 of enrollment: pull a verified parent card from PENDING to ACTIVE.
732
+
733
+ After the cardholder clicks the issuer's verification email (the email that
734
+ ``enroll_credit_card``'s step 2 triggers), this refreshes the card's status
735
+ via ``PATCH /creditcards/{id}/status``. Returns the card's current state —
736
+ still ``PENDING`` if verification has not completed, ``ACTIVE`` once it has.
737
+ """
738
+ c = client or _default_client()
739
+ resp = c.patch(f"/creditcards/{credit_card_id}/status", json_body={})
740
+ return _parse_credit_card(resp, "activate response")
741
+
742
+
743
+ def reveal_card(
744
+ card_id: str,
745
+ *,
746
+ client: Any = None,
747
+ ) -> dict[str, Any]:
748
+ """Retrieve live card credentials from the vault host.
749
+
750
+ Returns raw credentials in-memory: number, CVC, last4, and expiry.
751
+ """
752
+ from .client import vault_client
753
+
754
+ c = client or vault_client()
755
+ card = _require(c.get(f"/virtualcards/{card_id}"), "virtualCard", "reveal_card response")
756
+ number: str = _require(card, "vcn", "reveal_card response")
757
+ cvc: str = _require(card, "securityCode", "reveal_card response")
758
+ expires: str | None = card.get("expires")
759
+ last4: str | None = card.get("last4")
760
+
761
+ return {"number": number, "cvc": cvc, "last4": last4, "expires": expires}