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/ledger.py ADDED
@@ -0,0 +1,535 @@
1
+ """JSONL ledger for PayWithExtend virtual cards.
2
+
3
+ The Extend API is the source of truth, but this ledger is the local audit trail
4
+ for created cards and in-flight mutations. It intentionally never stores PAN or
5
+ CVC data.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import dataclasses
12
+ import inspect
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import tempfile
18
+ from collections.abc import Callable, Iterable, Mapping
19
+ from contextlib import contextmanager
20
+ from datetime import date, datetime, timezone
21
+ from decimal import Decimal
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from filelock import FileLock
26
+
27
+ from extendvcc._jsonl import append_jsonl
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _ledger_path() -> Path:
33
+ from extendvcc._paths import ledger_path
34
+
35
+ return ledger_path()
36
+
37
+
38
+ CARD_RECORD_TYPE = "card"
39
+ OPERATION_RECORD_TYPE = "operation"
40
+ PENDING_STATUS = "pending"
41
+ RESOLVED_STATUSES = {"confirmed", "failed"}
42
+
43
+ _SENSITIVE_FIELD_NAMES = {
44
+ "account_number",
45
+ "card_number",
46
+ "card_identification_number",
47
+ "card_security_number",
48
+ "card_security_value",
49
+ "card_validation_code",
50
+ "card_validation_value",
51
+ "card_verification_code",
52
+ "card_verification_value",
53
+ "cid",
54
+ "cc_number",
55
+ "credit_card_number",
56
+ "csc",
57
+ "cvc",
58
+ "cvv",
59
+ "cvn",
60
+ "cvv2",
61
+ "full_card_number",
62
+ "vcn",
63
+ "number",
64
+ "pan",
65
+ "primary_account_number",
66
+ "security_code",
67
+ "verification_code",
68
+ }
69
+ _SENSITIVE_KEY_FRAGMENTS = (
70
+ "_cvc",
71
+ "cvc_",
72
+ "_cvv",
73
+ "cvv_",
74
+ "security_code",
75
+ "verification_code",
76
+ )
77
+ _SENSITIVE_COMPACT_KEY_FRAGMENTS = (
78
+ "accountnumber",
79
+ "cardnumber",
80
+ "cardidentificationnumber",
81
+ "cardsecuritynumber",
82
+ "cardsecurityvalue",
83
+ "cardvalidationcode",
84
+ "cardvalidationvalue",
85
+ "cardverificationvalue",
86
+ "ccnumber",
87
+ "creditcardnumber",
88
+ "cid",
89
+ "csc",
90
+ "cvc",
91
+ "cvv",
92
+ "cvn",
93
+ "pan",
94
+ "primaryaccountnumber",
95
+ "securitycode",
96
+ "securitynumber",
97
+ "securityvalue",
98
+ "validationcode",
99
+ "validationvalue",
100
+ "verificationcode",
101
+ "verificationvalue",
102
+ )
103
+ _PAN_CANDIDATE_RE = re.compile(r"(?:\d[ -]?){13,19}")
104
+ _CAMEL_BOUNDARY_RE = re.compile(r"(?<!^)(?=[A-Z])")
105
+
106
+
107
+ def append(card_record: Mapping[str, Any] | Any) -> dict[str, Any]:
108
+ """Append a new card row to the ledger.
109
+
110
+ Raises:
111
+ ValueError: if the row has no card id, duplicates an existing card id,
112
+ or contains PAN/CVC-like data.
113
+ """
114
+ record = _normalize_card_record(card_record)
115
+ card_id = record["card_id"]
116
+
117
+ with _ledger_lock():
118
+ records = _read_records_unlocked()
119
+ if _find_card_index(records, card_id) is not None:
120
+ raise ValueError(f"card already exists in ledger: {card_id}")
121
+ append_jsonl(_ledger_path(), [record], fsync=True)
122
+ return record
123
+
124
+
125
+ def record_pending(intent: str, correlation_key: str) -> dict[str, Any]:
126
+ """Record a pending mutation before dispatching it to Extend."""
127
+ if not intent:
128
+ raise ValueError("intent is required")
129
+ if not correlation_key:
130
+ raise ValueError("correlation_key is required")
131
+
132
+ record = {
133
+ "record_type": OPERATION_RECORD_TYPE,
134
+ "status": PENDING_STATUS,
135
+ "intent": str(intent),
136
+ "correlation_key": str(correlation_key),
137
+ "created_at": _now_iso(),
138
+ }
139
+
140
+ with _ledger_lock():
141
+ records = _read_records_unlocked()
142
+ if _find_pending_index(records, str(correlation_key)) is not None:
143
+ raise ValueError(f"pending operation already exists: {correlation_key}")
144
+ append_jsonl(_ledger_path(), [record], fsync=True)
145
+ return record
146
+
147
+
148
+ def resolve_pending(
149
+ correlation_key: str,
150
+ status: str,
151
+ **fields: Any,
152
+ ) -> dict[str, Any]:
153
+ """Resolve a pending operation as ``confirmed`` or ``failed``.
154
+
155
+ If ``card_record=...`` is supplied while confirming, the card row is added
156
+ in the same locked atomic rewrite as the operation resolution.
157
+ """
158
+ if not correlation_key:
159
+ raise ValueError("correlation_key is required")
160
+ resolved_status = str(status).lower()
161
+ if resolved_status not in RESOLVED_STATUSES:
162
+ raise ValueError(f"status must be one of {sorted(RESOLVED_STATUSES)}")
163
+ _assert_no_sensitive_data(fields)
164
+
165
+ card_record = fields.pop("card_record", None)
166
+ normalized_card = None
167
+ if card_record is not None:
168
+ if resolved_status != "confirmed":
169
+ raise ValueError("card_record can only be stored for confirmed operations")
170
+ normalized_card = _normalize_card_record(card_record)
171
+
172
+ with _ledger_lock():
173
+ records = _read_records_unlocked()
174
+ pending_index = _find_pending_index(records, str(correlation_key))
175
+ if pending_index is None:
176
+ raise KeyError(f"no pending operation found: {correlation_key}")
177
+
178
+ operation = dict(records[pending_index])
179
+ operation.update(fields)
180
+ operation["status"] = resolved_status
181
+ operation["resolved_at"] = _now_iso()
182
+ records[pending_index] = operation
183
+
184
+ if normalized_card is not None:
185
+ card_id = normalized_card["card_id"]
186
+ existing_card_index = _find_card_index(records, card_id)
187
+ if existing_card_index is None:
188
+ records.append(normalized_card)
189
+ else:
190
+ records[existing_card_index] = {
191
+ **records[existing_card_index],
192
+ **normalized_card,
193
+ }
194
+
195
+ _atomic_write_records_unlocked(records)
196
+ return operation
197
+
198
+
199
+ def update(card_id: str, **fields: Any) -> dict[str, Any]:
200
+ """Update a card row by ``card_id`` using lock + atomic rewrite."""
201
+ if not card_id:
202
+ raise ValueError("card_id is required")
203
+ if "card_id" in fields:
204
+ raise ValueError("card_id cannot be changed")
205
+ _assert_no_sensitive_data(fields)
206
+
207
+ with _ledger_lock():
208
+ records = _read_records_unlocked()
209
+ card_index = _find_card_index(records, str(card_id))
210
+ if card_index is None:
211
+ raise KeyError(f"card not found in ledger: {card_id}")
212
+
213
+ card = dict(records[card_index])
214
+ card.update(fields)
215
+ records[card_index] = card
216
+ _atomic_write_records_unlocked(records)
217
+ return card
218
+
219
+
220
+ def query(
221
+ status: str | None = None,
222
+ name_pattern: str | None = None,
223
+ ) -> list[dict[str, Any]]:
224
+ """Return card rows matching the optional status and name filters."""
225
+ status_filter = _status_value(status)
226
+ name_re = re.compile(name_pattern, re.IGNORECASE) if name_pattern else None
227
+
228
+ with _ledger_lock():
229
+ records = _read_records_unlocked()
230
+
231
+ matches: list[dict[str, Any]] = []
232
+ for record in records:
233
+ if not _is_card_record(record):
234
+ continue
235
+ if status_filter is not None and _status_value(record.get("status")) != status_filter:
236
+ continue
237
+ if name_re is not None and not name_re.search(str(record.get("name", ""))):
238
+ continue
239
+ matches.append(dict(record))
240
+ return matches
241
+
242
+
243
+ def find_pending(correlation_key: str) -> dict[str, Any] | None:
244
+ """Return a copy of the pending operation row for ``correlation_key``, or None."""
245
+ if not correlation_key:
246
+ return None
247
+ with _ledger_lock():
248
+ records = _read_records_unlocked()
249
+ index = _find_pending_index(records, correlation_key)
250
+ if index is None:
251
+ return None
252
+ return dict(records[index])
253
+
254
+
255
+ def list_pending(intent: str | None = None) -> list[dict[str, Any]]:
256
+ """Return copies of all pending operation rows, optionally filtered by intent."""
257
+ with _ledger_lock():
258
+ records = _read_records_unlocked()
259
+ result: list[dict[str, Any]] = []
260
+ for record in records:
261
+ if record.get("record_type") != OPERATION_RECORD_TYPE:
262
+ continue
263
+ if record.get("status") != PENDING_STATUS:
264
+ continue
265
+ if intent is not None and record.get("intent") != intent:
266
+ continue
267
+ result.append(dict(record))
268
+ return result
269
+
270
+
271
+ def sync(fetcher: Callable[[], Any] | None = None) -> dict[str, Any]:
272
+ """Fetch all Extend cards and reconcile them into the local ledger.
273
+
274
+ ``fetcher`` is injectable for tests and future card-operation code. If it
275
+ is omitted, this performs the read-only ``GET /virtualcards`` path directly.
276
+ """
277
+ from .client import assert_not_disabled
278
+
279
+ assert_not_disabled()
280
+ fetched_cards = _normalize_cards_response(_fetch_cards(fetcher))
281
+
282
+ added: list[str] = []
283
+ updated: dict[str, dict[str, Any]] = {}
284
+ unchanged: list[str] = []
285
+
286
+ with _ledger_lock():
287
+ records = _read_records_unlocked()
288
+
289
+ for fetched in fetched_cards:
290
+ card = _normalize_card_record(fetched)
291
+ card_id = card["card_id"]
292
+ card_index = _find_card_index(records, card_id)
293
+
294
+ if card_index is None:
295
+ records.append(card)
296
+ added.append(card_id)
297
+ continue
298
+
299
+ current = records[card_index]
300
+ changed_fields = {key: value for key, value in card.items() if current.get(key) != value}
301
+ if changed_fields:
302
+ records[card_index] = {**current, **changed_fields}
303
+ updated[card_id] = changed_fields
304
+ else:
305
+ unchanged.append(card_id)
306
+
307
+ _atomic_write_records_unlocked(records)
308
+
309
+ return {
310
+ "fetched": len(fetched_cards),
311
+ "added": added,
312
+ "updated": updated,
313
+ "unchanged": unchanged,
314
+ }
315
+
316
+
317
+ @contextmanager
318
+ def _ledger_lock() -> Iterable[None]:
319
+ """Hold an advisory lock for all ledger writes and consistent reads."""
320
+ path = _ledger_path()
321
+ path.parent.mkdir(parents=True, exist_ok=True)
322
+ lock_path = path.with_suffix(path.suffix + ".lock")
323
+ lock = FileLock(str(lock_path))
324
+ with lock:
325
+ yield
326
+
327
+
328
+ def _read_records_unlocked() -> list[dict[str, Any]]:
329
+ path = _ledger_path()
330
+ if not path.exists():
331
+ return []
332
+
333
+ records: list[dict[str, Any]] = []
334
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
335
+ line = line.strip()
336
+ if not line:
337
+ continue
338
+ try:
339
+ record = json.loads(line)
340
+ except json.JSONDecodeError as exc:
341
+ # Skip (don't raise) so one torn line — e.g. a crash mid-append or a
342
+ # Syncthing conflict — cannot brick every ledger read and all card ops.
343
+ logger.error("skipping malformed ledger JSON at %s:%d: %s", path, lineno, exc)
344
+ continue
345
+ if not isinstance(record, dict):
346
+ logger.error("skipping non-object ledger row at %s:%d", path, lineno)
347
+ continue
348
+ records.append(record)
349
+ return records
350
+
351
+
352
+ def _atomic_write_records_unlocked(records: list[dict[str, Any]]) -> None:
353
+ path = _ledger_path()
354
+ path.parent.mkdir(parents=True, exist_ok=True)
355
+ fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
356
+ try:
357
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
358
+ for record in records:
359
+ tmp_file.write(json.dumps(record, sort_keys=True, ensure_ascii=False))
360
+ tmp_file.write("\n")
361
+ tmp_file.flush()
362
+ os.fsync(tmp_file.fileno())
363
+ os.replace(tmp_name, path)
364
+ except Exception:
365
+ try:
366
+ os.unlink(tmp_name)
367
+ except FileNotFoundError:
368
+ pass
369
+ raise
370
+
371
+
372
+ def _fetch_cards(fetcher: Callable[[], Any] | None) -> Any:
373
+ if fetcher is None:
374
+ # Default to the page-based list_cards paginator (the scheme the live
375
+ # Extend API actually uses). Lazy import avoids a cards<->ledger cycle.
376
+ from . import cards
377
+
378
+ fetcher = cards.list_cards
379
+
380
+ result = fetcher()
381
+ if inspect.isawaitable(result):
382
+ return _run_awaitable(result)
383
+ return result
384
+
385
+
386
+ def _run_awaitable(awaitable: Any) -> Any:
387
+ try:
388
+ asyncio.get_running_loop()
389
+ except RuntimeError:
390
+ return asyncio.run(awaitable)
391
+ raise RuntimeError("ledger.sync cannot run an async fetcher inside an active event loop")
392
+
393
+
394
+ def _normalize_cards_response(response: Any) -> list[Any]:
395
+ if response is None:
396
+ return []
397
+ if isinstance(response, Mapping):
398
+ for key in ("cards", "items", "data", "results", "virtual_cards", "virtualCards"):
399
+ value = response.get(key)
400
+ if isinstance(value, list):
401
+ return value
402
+ return [response]
403
+ if isinstance(response, list):
404
+ return response
405
+ return list(response)
406
+
407
+
408
+ def _normalize_card_record(card_record: Mapping[str, Any] | Any) -> dict[str, Any]:
409
+ record = _object_to_dict(card_record)
410
+ record = {_snake_key(str(key)): value for key, value in record.items()}
411
+ if "card_id" not in record and "id" in record:
412
+ record["card_id"] = record.pop("id")
413
+ if "name" not in record and "display_name" in record:
414
+ record["name"] = record["display_name"]
415
+ record["record_type"] = CARD_RECORD_TYPE
416
+
417
+ card_id = record.get("card_id")
418
+ if not card_id:
419
+ raise ValueError("card_record requires card_id or id")
420
+ record["card_id"] = str(card_id)
421
+
422
+ # Coerce date/datetime values to ISO strings so json.dumps does not crash.
423
+ record = {key: value.isoformat() if isinstance(value, (date, datetime)) else value for key, value in record.items()}
424
+
425
+ _assert_no_sensitive_data(record)
426
+ return record
427
+
428
+
429
+ def _object_to_dict(value: Mapping[str, Any] | Any) -> dict[str, Any]:
430
+ if dataclasses.is_dataclass(value):
431
+ return dataclasses.asdict(value)
432
+ if isinstance(value, Mapping):
433
+ return dict(value)
434
+ if hasattr(value, "model_dump"):
435
+ return dict(value.model_dump())
436
+ if hasattr(value, "dict"):
437
+ return dict(value.dict())
438
+ if hasattr(value, "__dict__"):
439
+ return dict(vars(value))
440
+ raise TypeError("card record must be a mapping or dataclass-like object")
441
+
442
+
443
+ def _find_card_index(records: list[dict[str, Any]], card_id: str) -> int | None:
444
+ for index, record in enumerate(records):
445
+ if _is_card_record(record) and str(record.get("card_id")) == card_id:
446
+ return index
447
+ return None
448
+
449
+
450
+ def _find_pending_index(records: list[dict[str, Any]], correlation_key: str) -> int | None:
451
+ for index in range(len(records) - 1, -1, -1):
452
+ record = records[index]
453
+ if (
454
+ record.get("record_type") == OPERATION_RECORD_TYPE
455
+ and record.get("correlation_key") == correlation_key
456
+ and record.get("status") == PENDING_STATUS
457
+ ):
458
+ return index
459
+ return None
460
+
461
+
462
+ def _is_card_record(record: Mapping[str, Any]) -> bool:
463
+ record_type = record.get("record_type")
464
+ return record_type == CARD_RECORD_TYPE or (record_type is None and "card_id" in record)
465
+
466
+
467
+ def _assert_no_sensitive_data(value: Any, path: str = "record") -> None:
468
+ if isinstance(value, Mapping):
469
+ for key, item in value.items():
470
+ key_text = str(key)
471
+ normalized_key = _snake_key(key_text).lower()
472
+ compact_key = normalized_key.replace("_", "")
473
+ if (
474
+ normalized_key in _SENSITIVE_FIELD_NAMES
475
+ or any(fragment in f"_{normalized_key}_" for fragment in _SENSITIVE_KEY_FRAGMENTS)
476
+ or any(fragment in compact_key for fragment in _SENSITIVE_COMPACT_KEY_FRAGMENTS)
477
+ or _looks_like_credential_key(compact_key)
478
+ ):
479
+ raise ValueError(f"refusing to store sensitive card field: {path}.{key_text}")
480
+ _assert_no_sensitive_data(item, f"{path}.{key_text}")
481
+ return
482
+ if isinstance(value, list | tuple):
483
+ for index, item in enumerate(value):
484
+ _assert_no_sensitive_data(item, f"{path}[{index}]")
485
+ return
486
+ if isinstance(value, (str, int, float, Decimal)) and _contains_luhn_pan(str(value)):
487
+ raise ValueError(f"refusing to store possible PAN value at {path}")
488
+
489
+
490
+ def _looks_like_credential_key(compact_key: str) -> bool:
491
+ if compact_key in {"cid", "csc", "cvc", "cvv", "cvn", "cvv2"}:
492
+ return True
493
+ if any(marker in compact_key for marker in ("verification", "validation", "security")):
494
+ return any(suffix in compact_key for suffix in ("code", "value", "number"))
495
+ if any(marker in compact_key for marker in ("card", "creditcard", "account", "primaryaccount")):
496
+ return "number" in compact_key or "pan" in compact_key
497
+ if "identification" in compact_key:
498
+ return "number" in compact_key
499
+ return False
500
+
501
+
502
+ def _contains_luhn_pan(value: str) -> bool:
503
+ for match in _PAN_CANDIDATE_RE.finditer(value):
504
+ digits = re.sub(r"\D", "", match.group(0))
505
+ if len(digits) >= 13 and len(set(digits)) > 1 and _luhn_valid(digits):
506
+ return True
507
+ return False
508
+
509
+
510
+ def _luhn_valid(digits: str) -> bool:
511
+ total = 0
512
+ parity = len(digits) % 2
513
+ for index, char in enumerate(digits):
514
+ digit = int(char)
515
+ if index % 2 == parity:
516
+ digit *= 2
517
+ if digit > 9:
518
+ digit -= 9
519
+ total += digit
520
+ return total % 10 == 0
521
+
522
+
523
+ def _snake_key(key: str) -> str:
524
+ return _CAMEL_BOUNDARY_RE.sub("_", key).replace("-", "_").lower()
525
+
526
+
527
+ def _status_value(status: Any) -> str | None:
528
+ if status is None:
529
+ return None
530
+ value = getattr(status, "value", status)
531
+ return str(value).lower()
532
+
533
+
534
+ def _now_iso() -> str:
535
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
extendvcc/models.py ADDED
@@ -0,0 +1,74 @@
1
+ """PayWithExtend data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+ from enum import StrEnum
8
+
9
+
10
+ class CardStatus(StrEnum):
11
+ ACTIVE = "ACTIVE"
12
+ CANCELLED = "CANCELLED"
13
+ EXPIRED = "EXPIRED"
14
+ PENDING = "PENDING"
15
+ CLOSED = "CLOSED"
16
+ CONSUMED = "CONSUMED"
17
+ # A newly enrolled parent card sits here until the issuer's email
18
+ # verification completes (observed on Amex enrollment 2026-06-14).
19
+ NOT_APPLICABLE = "NOT_APPLICABLE"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class VirtualCard:
24
+ id: str
25
+ credit_card_id: str
26
+ name: str
27
+ last4: str
28
+ status: CardStatus
29
+ balance_cents: int
30
+ valid_from: date | None
31
+ valid_to: date | None
32
+ notes: str | None
33
+ created_at: datetime | None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Recurrence:
38
+ """Recurring virtual-card reset schedule (Extend's ``recurrence`` object).
39
+
40
+ All three periods and all three terminators are captured from live traffic.
41
+
42
+ Fields:
43
+ period: "DAILY" | "WEEKLY" | "MONTHLY".
44
+ interval: reset every N periods (>= 1).
45
+ terminator: "NONE" (never ends) | "DATE" (ends on ``until``) |
46
+ "COUNT" (ends after ``count`` resets).
47
+ by_month_day: MONTHLY only — day-of-month (1..31) the limit resets on.
48
+ by_week_day: WEEKLY only — day index (0..6, the UI's own index).
49
+ until: DATE terminator only — "YYYY-MM-DD" the recurrence stops.
50
+ count: COUNT terminator only — number of resets before stopping.
51
+ """
52
+
53
+ period: str
54
+ interval: int = 1
55
+ terminator: str = "NONE"
56
+ by_month_day: int | None = None
57
+ by_week_day: int | None = None
58
+ until: str | None = None
59
+ count: int | None = None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Issuer:
64
+ id: str
65
+ name: str
66
+ code: str
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class CreditCard:
71
+ id: str
72
+ last4: str
73
+ status: CardStatus
74
+ display_name: str
extendvcc/py.typed ADDED
File without changes