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/__init__.py +46 -0
- extendvcc/_exit_codes.py +24 -0
- extendvcc/_jsonl.py +35 -0
- extendvcc/_paths.py +28 -0
- extendvcc/auth.py +900 -0
- extendvcc/cards.py +761 -0
- extendvcc/cli.py +883 -0
- extendvcc/client.py +491 -0
- extendvcc/imap_otp.py +170 -0
- extendvcc/ledger.py +535 -0
- extendvcc/models.py +74 -0
- extendvcc/py.typed +0 -0
- extendvcc_cli-0.1.0.dist-info/METADATA +179 -0
- extendvcc_cli-0.1.0.dist-info/RECORD +17 -0
- extendvcc_cli-0.1.0.dist-info/WHEEL +4 -0
- extendvcc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- extendvcc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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}
|