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/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
|