diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76ee98e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + unit: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package and test extras + run: python -m pip install --upgrade pip '.[dev]' + + - name: Run unit tests + run: pytest tests/unit diff --git a/README.md b/README.md index 55a8f3a..b33fdb0 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,22 @@ deposit = blnk.transactions.create({ "destination": balance.data["balance_id"], "allow_overdraft": True, }) + +# Core 0.15.3+: preview without writing (HTTP 200, no transaction_id) +preview = blnk.transactions.create({ + "amount": 750, + "precision": 100, + "currency": "USD", + "reference": "ref_001adcfgf", + "description": "First deposit", + "source": "@WorldUSD", + "destination": balance.data["balance_id"], + "allow_overdraft": True, + "dry_run": True, +}) +print(preview.status) # 200 +print(preview.data["would_apply"]) # True/False +print(preview.data["balances"]) # current_* / resulting_* projections ``` The client is a context manager — `with blnk_init(...) as blnk:` closes the underlying @@ -120,7 +136,8 @@ SDK methods **never raise** for request or validation failures — they return a Blnk Core returns a structured `error_detail` body, its message is used. - `data` — the parsed JSON body (`None` on failure or empty body). - `error` — a structured `BlnkApiErrorDetail(code, message, details)` when the Core - returned a JSON error body. + returned a JSON error body. Compare `error.code` to `BlnkErrorCode` + (`TXN_VALIDATION_ERROR`, `GEN_CONFLICT`, `TXN_INVALID_AMOUNT`). Client-side validation runs before any request is sent: an invalid payload returns a `400` response immediately and the HTTP layer is never invoked. The only raising paths @@ -130,12 +147,17 @@ requesting an unregistered service. ## Tests ```sh -.venv/bin/pytest tests/ # 489 tests: 444 offline unit tests, 45 live-gated +.venv/bin/pytest tests/ # 482 offline unit tests; live-gated suites skip BLNK_E2E=1 .venv/bin/pytest tests/ # also runs integration + e2e against http://localhost:5001 +BLNK_E2E=1 .venv/bin/pytest tests/integration/test_core_0_15_3.py ``` Unit tests inject a mock transport and run fully offline. The live suites need a running -Blnk Core (`docker compose up` in the [blnk](https://github.com/blnkfinance/blnk) repo). +Blnk Core 0.15.3+ (`docker compose up` in the [blnk](https://github.com/blnkfinance/blnk) repo). + +A Postman collection for the same Core 0.15.3 checks lives at +`postman/blnk-core-0.15.3.collection.json`. Import it and set `base_url` plus +`api_key` in the collection variables. ## Project layout diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..f35d988 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,46 @@ +# Release Notes + +## v1.4.0 + +v1.4.0 targets **Blnk Core 0.15.3**. v1.3.0 shipped Core 0.15.0 parity; this +release adds dry-run previews, General Ledger `indicator` on create, refund +narration/metadata, and named Core error codes. + +See [Dry-run transactions](https://docs.blnkfinance.com/transactions/dry-run) +and the [Core changelog](https://docs.blnkfinance.com/changelog/blnk-core). + +### Transactions + +- **`dry_run`** — Optional on `transactions.create`, `create_bulk`, `refund`, + `update_status`, `bulk_commit_inflight`, and `bulk_void_inflight`. Core + returns HTTP 200 with a preview (`would_apply`, `rejection`, `balances`). + Nothing is written; `reference` is not consumed. Typed as + `TransactionPreview` / `BulkTransactionPreview`. Use + `is_transaction_preview` / `is_bulk_transaction_preview` to narrow a live + `ApiResponse.data` dict. [Guide](https://docs.blnkfinance.com/transactions/dry-run) + +- **`transactions.refund`** — Accepts `description` and `meta_data` in addition + to `skip_queue`. Empty description inherits the original; metadata is merged + onto the inherited copy. + [Reference](https://docs.blnkfinance.com/reference/refund-transaction) + +### Balances + +- **`ledger_balances.create`** — Optional `indicator` (must start with `@`, no + spaces) when `ledger_id` is `"general_ledger_id"`. Duplicate indicator + + currency returns `409` / `GEN_CONFLICT`. + [Guide](https://docs.blnkfinance.com/balances/internal-balances) + +### Hooks + +- **`hooks.list()`** — `type` remains optional. Omitting it lists PRE and POST + hooks (`GET /hooks`). + +### Errors + +- **`BlnkErrorCode`** — Exported constants for `TXN_INVALID_AMOUNT`, + `GEN_CONFLICT`, and `TXN_VALIDATION_ERROR`. Core 0.15.3 uses + `TXN_VALIDATION_ERROR` for negative amounts and for source equal to + destination; duplicate GL indicators return `GEN_CONFLICT`. Branch on + `response.error.code`. + [Guide](https://docs.blnkfinance.com/advanced/error-codes) diff --git a/blnk_sdk/__init__.py b/blnk_sdk/__init__.py index 6687816..71bf25d 100644 --- a/blnk_sdk/__init__.py +++ b/blnk_sdk/__init__.py @@ -9,6 +9,7 @@ ) from .errors import ( BlnkApiErrorDetail, + BlnkErrorCode, BlnkServiceError, BlnkTimeoutError, parse_blnk_api_error_body, @@ -18,12 +19,13 @@ from .multipart import MultipartBody, is_multipart_body from .transport import RequestsTransport, TransportRequest -__version__ = "1.3.0" +__version__ = "1.4.0" __all__ = [ "ApiResponse", "Blnk", "BlnkApiErrorDetail", + "BlnkErrorCode", "BlnkClientOptions", "BlnkServiceError", "BlnkTimeoutError", diff --git a/blnk_sdk/errors.py b/blnk_sdk/errors.py index fba9c49..526d6d5 100644 --- a/blnk_sdk/errors.py +++ b/blnk_sdk/errors.py @@ -22,6 +22,21 @@ class BlnkServiceError(RuntimeError): """ +class BlnkErrorCode: + """Stable Core ``error_detail.code`` values called out for SDK 1.4.0 / + Core 0.15.3. Compare ``response.error.code`` — do not branch on message + text. + + See https://docs.blnkfinance.com/advanced/error-codes + """ + + TXN_INVALID_AMOUNT = "TXN_INVALID_AMOUNT" + GEN_CONFLICT = "GEN_CONFLICT" + # Request failed validation before processing. On Core 0.15.3 this + # includes negative amount/precision and source equal to destination. + TXN_VALIDATION_ERROR = "TXN_VALIDATION_ERROR" + + @dataclass class BlnkApiErrorDetail: """Structured API error attached to ApiResponse.error.""" diff --git a/blnk_sdk/services/hooks.py b/blnk_sdk/services/hooks.py index dc8628d..54193f9 100644 --- a/blnk_sdk/services/hooks.py +++ b/blnk_sdk/services/hooks.py @@ -63,10 +63,14 @@ def create(self, data: Any) -> Any: return handle_error(error, self._logger, self._format_response, "create") def list(self, options: Any = None) -> Any: - """GET hooks, or hooks?type={type} when a type is provided. A type - key explicitly set to None fails validation before the endpoint is - built, so the `is not None` check only ever distinguishes absent - from present; the enum value is interpolated RAW (no encoding).""" + """GET hooks, or hooks?type={type} when a type is provided. + + Omitting ``type`` (``hooks.list()``) lists PRE and POST hooks. + A type key explicitly set to None fails validation before the + endpoint is built, so the `is not None` check only ever + distinguishes absent from present; the enum value is interpolated + RAW (no encoding). + """ try: validator_response = validate_list_hooks_options(options) if validator_response: diff --git a/blnk_sdk/services/ledger_balances.py b/blnk_sdk/services/ledger_balances.py index 5f50005..289de0b 100644 --- a/blnk_sdk/services/ledger_balances.py +++ b/blnk_sdk/services/ledger_balances.py @@ -54,7 +54,13 @@ def __init__( self._format_response = format_response def create(self, data: Any) -> Any: - """POST balances — body forwarded unmodified.""" + """POST balances — body forwarded unmodified. + + Optional ``indicator`` (must start with ``@``, no whitespace) creates + an internal General Ledger balance when ``ledger_id`` is + ``general_ledger_id``. Duplicate indicator + currency returns 409 / + GEN_CONFLICT. + """ try: error = validate_create_ledger_balance(data) if error: diff --git a/blnk_sdk/services/transactions.py b/blnk_sdk/services/transactions.py index 10b43c5..d3e1571 100644 --- a/blnk_sdk/services/transactions.py +++ b/blnk_sdk/services/transactions.py @@ -51,7 +51,11 @@ def __init__( self._format_response = format_response def create(self, data: Any) -> Any: - """POST transactions — body is serialize_create_transaction(data).""" + """POST transactions — body is serialize_create_transaction(data). + + Set ``dry_run=True`` to preview without writing (HTTP 200, + TransactionPreview). ``dry_run`` takes precedence over ``skip_queue``. + """ try: view = _dict_view(data) validator_response = validate_create_transactions(view) @@ -66,7 +70,10 @@ def create(self, data: Any) -> Any: def update_status(self, id: str, update: Any) -> Any: """PUT transactions/inflight/{id} — body sent as-is; the id is never - validated nor URL-encoded.""" + validated nor URL-encoded. + + Set ``dry_run=True`` to preview commit/void without settling the hold. + """ try: view = _dict_view(update) validator_response = validate_update_transactions(view) @@ -80,7 +87,12 @@ def update_status(self, id: str, update: Any) -> Any: def refund(self, id: str, options: Optional[Any] = None) -> Any: """POST refund-transaction/{id} — optional body (None when no options - are given); the id is never validated nor URL-encoded.""" + are given); the id is never validated nor URL-encoded. + + Options: ``skip_queue``, ``description``, ``meta_data``, ``dry_run``. + Empty description inherits the original; metadata is merged onto the + inherited copy. ``dry_run=True`` previews without writing. + """ try: body = None if options is not None: @@ -160,7 +172,10 @@ def recover_queue(self, options: Optional[Any] = None) -> Any: ) def bulk_commit_inflight(self, data: Any) -> Any: - """POST transactions/inflight/bulk/commit — body sent as-is.""" + """POST transactions/inflight/bulk/commit — body sent as-is. + + Set ``dry_run=True`` to preview without committing (holds stay INFLIGHT). + """ try: view = _dict_view(data) validator_response = validate_bulk_commit_inflight(view) @@ -176,7 +191,10 @@ def bulk_commit_inflight(self, data: Any) -> Any: ) def bulk_void_inflight(self, data: Any) -> Any: - """POST transactions/inflight/bulk/void — body sent as-is.""" + """POST transactions/inflight/bulk/void — body sent as-is. + + Set ``dry_run=True`` to preview without voiding (holds stay INFLIGHT). + """ try: view = _dict_view(data) validator_response = validate_bulk_void_inflight(view) @@ -191,7 +209,12 @@ def bulk_void_inflight(self, data: Any) -> Any: def create_bulk(self, data: Any) -> Any: """POST transactions/bulk — each item runs through - serialize_create_transaction; top-level flags pass through.""" + serialize_create_transaction; top-level flags pass through. + + Set ``dry_run=True`` to preview the batch (HTTP 200, + BulkTransactionPreview). ``run_async`` is ignored on a dry run; + ``skip_queue`` still selects cumulative vs independent projection. + """ try: view = _dict_view(data) validator_response = validate_bulk_transactions(view) diff --git a/blnk_sdk/types/hooks.py b/blnk_sdk/types/hooks.py index b85ab75..8391a34 100644 --- a/blnk_sdk/types/hooks.py +++ b/blnk_sdk/types/hooks.py @@ -41,7 +41,12 @@ class CreateHookData(DTO): @dataclass class ListHooksOptions(DTO): - """Options for `GET hooks` (optional `?type=` filter).""" + """Options for `GET hooks` (optional `?type=` filter). + + Omit ``type`` (or call ``hooks.list()`` with no options) to list PRE + and POST hooks together. Core 0.15.3+ returns both when the filter is + absent. + """ type: Optional[HookType] = None diff --git a/blnk_sdk/types/ledger_balances.py b/blnk_sdk/types/ledger_balances.py index 73885b5..19697aa 100644 --- a/blnk_sdk/types/ledger_balances.py +++ b/blnk_sdk/types/ledger_balances.py @@ -26,6 +26,9 @@ class CreateLedgerBalance(DTO): ledger_id: str identity_id: Optional[str] = None currency: str + # Internal General Ledger indicator (must start with @, no whitespace). + # Only valid when ledger_id is "general_ledger_id". + indicator: Optional[str] = None # Enables fund lineage tracking. Requires identity_id. track_fund_lineage: Optional[bool] = None # How tagged provider funds are allocated when spending. Defaults to FIFO. diff --git a/blnk_sdk/types/transactions.py b/blnk_sdk/types/transactions.py index fe424fe..79ae556 100644 --- a/blnk_sdk/types/transactions.py +++ b/blnk_sdk/types/transactions.py @@ -77,6 +77,9 @@ class CreateTransactions(DTO): skip_queue: Optional[bool] = None atomic: Optional[bool] = None allow_overdraft: Optional[bool] = None + # Preview the post without writing. Default: False. When True, Core + # returns HTTP 200 and a TransactionPreview — not a posted transaction. + dry_run: Optional[bool] = None meta_data: Optional[Any] = None @@ -126,6 +129,8 @@ class UpdateTransactionStatus(DTO): precise_amount: Optional[Union[int, float, str]] = None meta_data: Optional[Any] = None skip_queue: Optional[bool] = None + # Preview commit/void without settling the hold. Default: False. + dry_run: Optional[bool] = None @dataclass(kw_only=True) @@ -133,6 +138,12 @@ class RefundTransactionRequest(DTO): """Optional body for POST /refund-transaction/{transaction_id}.""" skip_queue: Optional[bool] = None + # Replaces the reversal description. Empty/omitted inherits the original. + description: Optional[str] = None + # Merged onto metadata inherited from the original transaction. + meta_data: Optional[Any] = None + # Preview the refund without writing. Default: False. + dry_run: Optional[bool] = None @dataclass(kw_only=True) @@ -143,6 +154,9 @@ class BulkTransactions(DTO): inflight: Optional[bool] = None run_async: Optional[bool] = None skip_queue: Optional[bool] = None + # Preview the batch without writing. Default: False. run_async is + # ignored on a bulk dry run. + dry_run: Optional[bool] = None transactions: List[CreateTransactions] = field(default_factory=list) @@ -170,6 +184,8 @@ class BulkCommitInflightRequest(DTO): """Request body for POST /transactions/inflight/bulk/commit.""" skip_queue: Optional[bool] = None + # Preview the batch without committing. Default: False. + dry_run: Optional[bool] = None transactions: List[BulkCommitInflightItem] = field(default_factory=list) @@ -197,6 +213,8 @@ class BulkVoidInflightRequest(DTO): """Request body for POST /transactions/inflight/bulk/void.""" skip_queue: Optional[bool] = None + # Preview the batch without voiding. Default: False. + dry_run: Optional[bool] = None transaction_ids: List[str] = field(default_factory=list) @@ -242,3 +260,138 @@ class RecoverQueueResponse(DTO): recovered: Union[int, float] threshold: str # e.g. "5m0s" + + +@dataclass(kw_only=True) +class PreviewRejection(DTO): + """Why a dry-run projection would not apply.""" + + code: str + reason: str + message: str + + +@dataclass(kw_only=True) +class BalanceProjection(DTO): + """One balance's current and projected state in a dry-run. + + Amounts are minor-unit strings. + """ + + balance_id: str + role: str + currency: str + virtual: Optional[bool] = None + current_balance: str + current_available: Optional[str] = None + current_credit_balance: Optional[str] = None + current_debit_balance: Optional[str] = None + current_inflight_debit_balance: Optional[str] = None + current_inflight_credit_balance: Optional[str] = None + resulting_balance: str + resulting_available: Optional[str] = None + resulting_credit_balance: Optional[str] = None + resulting_debit_balance: Optional[str] = None + resulting_inflight_debit_balance: Optional[str] = None + resulting_inflight_credit_balance: Optional[str] = None + + +@dataclass(kw_only=True) +class LegProjection(DTO): + """One split leg in a multi-source or multi-destination dry-run.""" + + identifier: str + role: str + precise_amount: str + amount: Union[int, float] + + +def _as_nested_dto(cls: Any, value: Any) -> Any: + if isinstance(value, dict): + return cls.from_dict(value) + return value + + +def _as_nested_dto_list(cls: Any, values: Any) -> Any: + if not isinstance(values, list): + return values + return [_as_nested_dto(cls, item) for item in values] + + +@dataclass(kw_only=True) +class TransactionPreview(DTO): + """Response from a dry-run create, refund, or inflight update (HTTP 200). + + This is a projection, not a recorded transaction — there is no + ``transaction_id`` and ``reference`` is not consumed. + + See https://docs.blnkfinance.com/transactions/dry-run + """ + + dry_run: bool + would_apply: bool + rejection: Optional[PreviewRejection] = None + operation: Optional[str] = None # "commit" | "void" on inflight previews + status: Optional[StatusType] = None + reference: Optional[str] = None + currency: str + amount: Union[int, float] + precise_amount: str + precision: Union[int, float] + balances: List[BalanceProjection] = field(default_factory=list) + legs: Optional[List[LegProjection]] = None + notes: Optional[List[str]] = None + + @classmethod + def from_dict(cls, data: Any): + obj = super().from_dict(data) + obj.rejection = _as_nested_dto(PreviewRejection, obj.rejection) + obj.balances = _as_nested_dto_list(BalanceProjection, obj.balances) or [] + obj.legs = _as_nested_dto_list(LegProjection, obj.legs) + return obj + + +@dataclass(kw_only=True) +class BulkTransactionPreview(DTO): + """Response from a dry-run bulk create / bulk inflight (HTTP 200). + + See https://docs.blnkfinance.com/transactions/dry-run + """ + + dry_run: bool + would_apply: bool + cumulative: bool + atomic: Optional[bool] = None + results: List[TransactionPreview] = field(default_factory=list) + balances: Optional[List[BalanceProjection]] = None + notes: Optional[List[str]] = None + + @classmethod + def from_dict(cls, data: Any): + obj = super().from_dict(data) + obj.results = _as_nested_dto_list(TransactionPreview, obj.results) or [] + obj.balances = _as_nested_dto_list(BalanceProjection, obj.balances) + return obj + + +def is_transaction_preview(data: Any) -> bool: + """True when a response body is a single-transaction dry-run preview.""" + if isinstance(data, TransactionPreview): + return True + return ( + isinstance(data, dict) + and data.get("dry_run") is True + and "would_apply" in data + and "results" not in data + ) + + +def is_bulk_transaction_preview(data: Any) -> bool: + """True when a response body is a bulk dry-run preview.""" + if isinstance(data, BulkTransactionPreview): + return True + return ( + isinstance(data, dict) + and data.get("dry_run") is True + and isinstance(data.get("results"), list) + ) diff --git a/blnk_sdk/validators/ledger_balance.py b/blnk_sdk/validators/ledger_balance.py index 47f1e7c..4f636ff 100644 --- a/blnk_sdk/validators/ledger_balance.py +++ b/blnk_sdk/validators/ledger_balance.py @@ -63,6 +63,17 @@ def validate_create_ledger_balance(data: Any) -> Optional[str]: if "identity_id" in d and not is_valid_string(d["identity_id"]): return "identity_id must be a valid string if provided" + if "indicator" in d: + indicator = d["indicator"] + if not is_valid_string(indicator) or indicator.strip() == "": + return "indicator must be a non-empty string if provided" + if not indicator.startswith("@"): + return "indicator must start with @" + if any(ch.isspace() for ch in indicator): + return "indicator must not contain spaces" + if d.get("ledger_id") != "general_ledger_id": + return "indicator is only valid when ledger_id is general_ledger_id" + # currency is only checked to be a string — any string passes, even # though the message names 'USD' and 'NGN'; callers depend on the exact # message text. diff --git a/blnk_sdk/validators/transaction_validators.py b/blnk_sdk/validators/transaction_validators.py index 36a2370..f354125 100644 --- a/blnk_sdk/validators/transaction_validators.py +++ b/blnk_sdk/validators/transaction_validators.py @@ -551,6 +551,9 @@ def validate_create_transactions(data: Any) -> Optional[str]: if "meta_data" in data and not is_valid_meta_data(data["meta_data"]): return "meta_data must be a valid object if provided" # note: no trailing period + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + # No unknown-field check here — extra fields flow through to the API. return None @@ -582,7 +585,17 @@ def validate_update_transactions(data: Any) -> Optional[str]: if "skip_queue" in data and not isinstance(data["skip_queue"], bool): return "skip_queue must be a boolean if provided." - allowed_fields = ["status", "amount", "precise_amount", "meta_data", "skip_queue"] + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + + allowed_fields = [ + "status", + "amount", + "precise_amount", + "meta_data", + "skip_queue", + "dry_run", + ] for key in data: # iterates in insertion order if key not in allowed_fields: return f"Invalid field: {key}" # note: no trailing period @@ -598,7 +611,16 @@ def validate_refund_transaction(data: Any) -> Optional[str]: if "skip_queue" in data and not isinstance(data["skip_queue"], bool): return "skip_queue must be a boolean if provided." - allowed_fields = ["skip_queue"] + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + + if "description" in data and not isinstance(data["description"], str): + return "description must be a string if provided." + + if "meta_data" in data and not is_valid_meta_data(data["meta_data"]): + return "meta_data must be a valid object if provided" + + allowed_fields = ["skip_queue", "dry_run", "description", "meta_data"] for key in data: if key not in allowed_fields: return f"Invalid field: {key}" @@ -613,6 +635,9 @@ def validate_bulk_void_inflight(data: Any) -> Optional[str]: if "skip_queue" in data and not isinstance(data["skip_queue"], bool): return "skip_queue must be a boolean if provided." + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + transaction_ids = data.get("transaction_ids") if not isinstance(transaction_ids, list): return "transaction_ids must be an array." @@ -627,7 +652,7 @@ def validate_bulk_void_inflight(data: Any) -> Optional[str]: if not isinstance(transaction_id, str) or transaction_id.strip() == "": return f"transaction_id is required at index {i}." - allowed_fields = ["skip_queue", "transaction_ids"] + allowed_fields = ["skip_queue", "dry_run", "transaction_ids"] for key in data: # unknown fields run LAST if key not in allowed_fields: return f"Invalid field: {key}" @@ -646,6 +671,9 @@ def validate_bulk_commit_inflight(data: Any) -> Optional[str]: if "skip_queue" in data and not isinstance(data["skip_queue"], bool): return "skip_queue must be a boolean if provided." + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + transactions = data.get("transactions") if not isinstance(transactions, list): return "Transactions must be an array." @@ -677,7 +705,7 @@ def validate_bulk_commit_inflight(data: Any) -> Optional[str]: f"or number at index {i}." ) - allowed_fields = ["skip_queue", "transactions"] + allowed_fields = ["skip_queue", "dry_run", "transactions"] for key in data: # unknown fields run LAST if key not in allowed_fields: return f"Invalid field: {key}" @@ -701,6 +729,9 @@ def validate_bulk_transactions(data: Any) -> Optional[str]: if "skip_queue" in data and not isinstance(data["skip_queue"], bool): return "skip_queue must be a boolean if provided." + if "dry_run" in data and not isinstance(data["dry_run"], bool): + return "dry_run must be a boolean if provided." + transactions = data.get("transactions") if not isinstance(transactions, list): return "Transactions must be an array." diff --git a/postman/blnk-core-0.15.3.collection.json b/postman/blnk-core-0.15.3.collection.json new file mode 100644 index 0000000..7a893a3 --- /dev/null +++ b/postman/blnk-core-0.15.3.collection.json @@ -0,0 +1,492 @@ +{ + "info": { + "name": "Blnk Core 0.15.3 — Python SDK acceptance", + "description": "Manual checks for blnk-python 1.4.0 / Core 0.15.3: dry-run, internal balances (indicator), refund description/meta_data, hooks list, and validation error codes.\n\nSet `base_url` (default http://localhost:5001) and `api_key`. Run the collection in order — later requests reuse ids saved by earlier tests.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "base_url", "value": "http://localhost:5001" }, + { "key": "api_key", "value": "blnk-local-dev-secret-change-me" }, + { "key": "indicator", "value": "" }, + { "key": "destination", "value": "" }, + { "key": "reuse_reference", "value": "" }, + { "key": "posted_id", "value": "" }, + { "key": "hold_id", "value": "" } + ], + "auth": { + "type": "apikey", + "apikey": [ + { "key": "key", "value": "X-Blnk-Key", "type": "string" }, + { "key": "value", "value": "{{api_key}}", "type": "string" }, + { "key": "in", "value": "header", "type": "string" } + ] + }, + "item": [ + { + "name": "Health", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('health is UP', function () {", + " pm.response.to.have.status(200);", + " pm.expect(pm.response.json().status).to.eql('UP');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/health" + } + }, + { + "name": "Create internal GL balance (indicator)", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const stamp = Date.now();", + "pm.collectionVariables.set('indicator', '@SdkPy' + stamp);", + "pm.collectionVariables.set('destination', '@SdkPyDest' + stamp);", + "pm.collectionVariables.set('reuse_reference', 'reuse-' + stamp);" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('creates GL balance with indicator', function () {", + " pm.response.to.have.status(201);", + " const body = pm.response.json();", + " pm.expect(body.indicator).to.eql(pm.collectionVariables.get('indicator'));", + " pm.expect(body.ledger_id).to.eql('general_ledger_id');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"ledger_id\": \"general_ledger_id\",\n \"currency\": \"USD\",\n \"indicator\": \"{{indicator}}\"\n}" + }, + "url": "{{base_url}}/balances" + } + }, + { + "name": "Dry-run create", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('dry-run create is a preview, not a post', function () {", + " pm.response.to.have.status(200);", + " const body = pm.response.json();", + " pm.expect(body.dry_run).to.eql(true);", + " pm.expect(body.would_apply).to.eql(true);", + " pm.expect(body.balances).to.be.an('array').that.is.not.empty;", + " pm.expect(body).to.not.have.property('transaction_id');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"dry_run\": true,\n \"amount\": 25,\n \"precision\": 100,\n \"reference\": \"{{reuse_reference}}\",\n \"description\": \"SDK dry-run\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Post same reference after dry-run", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('dry-run did not consume the reference', function () {", + " pm.response.to.have.status(201);", + " const body = pm.response.json();", + " pm.expect(body.transaction_id).to.be.a('string').that.is.not.empty;", + " pm.expect(body.reference).to.eql(pm.collectionVariables.get('reuse_reference'));", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 25,\n \"precision\": 100,\n \"reference\": \"{{reuse_reference}}\",\n \"description\": \"SDK dry-run\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true,\n \"skip_queue\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Dry-run bulk create", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('bulk dry-run returns results preview', function () {", + " pm.response.to.have.status(200);", + " const body = pm.response.json();", + " pm.expect(body.dry_run).to.eql(true);", + " pm.expect(body.would_apply).to.eql(true);", + " pm.expect(body.results).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"dry_run\": true,\n \"transactions\": [\n {\n \"amount\": 10,\n \"precision\": 100,\n \"reference\": \"bulk-a-{{$timestamp}}\",\n \"description\": \"bulk preview 1\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true\n },\n {\n \"amount\": 15,\n \"precision\": 100,\n \"reference\": \"bulk-b-{{$timestamp}}\",\n \"description\": \"bulk preview 2\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true\n }\n ]\n}" + }, + "url": "{{base_url}}/transactions/bulk" + } + }, + { + "name": "Post transaction for refund", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('posted transaction for refund', function () {", + " pm.response.to.have.status(201);", + " const body = pm.response.json();", + " pm.expect(body.transaction_id).to.be.ok;", + " pm.collectionVariables.set('posted_id', body.transaction_id);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 20,\n \"precision\": 100,\n \"reference\": \"posted-{{$timestamp}}\",\n \"description\": \"SDK posted for refund\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true,\n \"skip_queue\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Dry-run refund", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('refund dry-run is a preview', function () {", + " pm.response.to.have.status(200);", + " pm.expect(pm.response.json().dry_run).to.eql(true);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"dry_run\": true\n}" + }, + "url": "{{base_url}}/refund-transaction/{{posted_id}}" + } + }, + { + "name": "Refund with description and meta_data", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('refund accepts description and meta_data', function () {", + " pm.response.to.have.status(201);", + " const body = pm.response.json();", + " pm.expect(body.description).to.eql('SDK refund narration');", + " pm.expect(body.parent_transaction).to.eql(pm.collectionVariables.get('posted_id'));", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"skip_queue\": true,\n \"description\": \"SDK refund narration\",\n \"meta_data\": { \"channel\": \"postman\" }\n}" + }, + "url": "{{base_url}}/refund-transaction/{{posted_id}}" + } + }, + { + "name": "Create inflight hold", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('inflight hold created', function () {", + " pm.response.to.have.status(201);", + " const body = pm.response.json();", + " pm.expect(body.transaction_id).to.be.ok;", + " pm.collectionVariables.set('hold_id', body.transaction_id);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 30,\n \"precision\": 100,\n \"reference\": \"hold-{{$timestamp}}\",\n \"description\": \"SDK inflight hold\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true,\n \"inflight\": true,\n \"skip_queue\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Dry-run inflight commit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('inflight dry-run commit', function () {", + " pm.response.to.have.status(200);", + " const body = pm.response.json();", + " pm.expect(body.dry_run).to.eql(true);", + " pm.expect(body.operation).to.eql('commit');", + "});" + ] + } + } + ], + "request": { + "method": "PUT", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"commit\",\n \"dry_run\": true\n}" + }, + "url": "{{base_url}}/transactions/inflight/{{hold_id}}" + } + }, + { + "name": "Hold still INFLIGHT after dry-run commit", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('hold remains INFLIGHT', function () {", + " pm.response.to.have.status(200);", + " pm.expect(pm.response.json().status).to.eql('INFLIGHT');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/transactions/{{hold_id}}" + } + }, + { + "name": "Dry-run bulk commit inflight", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('bulk commit dry-run', function () {", + " pm.response.to.have.status(200);", + " const body = pm.response.json();", + " pm.expect(body.dry_run).to.eql(true);", + " pm.expect(body.would_apply).to.eql(true);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"dry_run\": true,\n \"skip_queue\": true,\n \"transactions\": [{ \"transaction_id\": \"{{hold_id}}\" }]\n}" + }, + "url": "{{base_url}}/transactions/inflight/bulk/commit" + } + }, + { + "name": "Dry-run bulk void inflight", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('bulk void dry-run', function () {", + " pm.response.to.have.status(200);", + " pm.expect(pm.response.json().dry_run).to.eql(true);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"dry_run\": true,\n \"skip_queue\": true,\n \"transaction_ids\": [\"{{hold_id}}\"]\n}" + }, + "url": "{{base_url}}/transactions/inflight/bulk/void" + } + }, + { + "name": "List hooks (no type filter)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('lists hooks or requires master key', function () {", + " pm.expect([200, 403]).to.include(pm.response.code);", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/hooks" + } + }, + { + "name": "Negative amount → TXN_VALIDATION_ERROR", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('negative amount is rejected with a validation code', function () {", + " pm.expect(pm.response.code).to.not.eql(201);", + " const body = pm.response.json();", + " const code = body.error_detail && body.error_detail.code;", + " pm.expect(code).to.eql('TXN_VALIDATION_ERROR');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": -1,\n \"precision\": 100,\n \"reference\": \"neg-{{$timestamp}}\",\n \"description\": \"invalid amount\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{destination}}\",\n \"allow_overdraft\": true,\n \"skip_queue\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Source equals destination", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('source == destination is rejected before processing', function () {", + " pm.expect(pm.response.code).to.not.eql(201);", + " const body = pm.response.json();", + " pm.expect(body.error_detail && body.error_detail.code).to.be.ok;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"amount\": 10,\n \"precision\": 100,\n \"reference\": \"same-{{$timestamp}}\",\n \"description\": \"same source dest\",\n \"currency\": \"USD\",\n \"source\": \"{{indicator}}\",\n \"destination\": \"{{indicator}}\",\n \"allow_overdraft\": true,\n \"skip_queue\": true\n}" + }, + "url": "{{base_url}}/transactions" + } + }, + { + "name": "Duplicate indicator → GEN_CONFLICT", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('duplicate GL indicator returns 409 GEN_CONFLICT', function () {", + " pm.response.to.have.status(409);", + " const body = pm.response.json();", + " pm.expect(body.error_detail && body.error_detail.code).to.eql('GEN_CONFLICT');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"ledger_id\": \"general_ledger_id\",\n \"currency\": \"USD\",\n \"indicator\": \"{{indicator}}\"\n}" + }, + "url": "{{base_url}}/balances" + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index c50428b..87fc6e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "blnk-python" -version = "1.3.0" +version = "1.4.0" description = "Python SDK for the Blnk Finance open-source ledger." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/integration/test_core_0_15_3.py b/tests/integration/test_core_0_15_3.py new file mode 100644 index 0000000..b3b8344 --- /dev/null +++ b/tests/integration/test_core_0_15_3.py @@ -0,0 +1,310 @@ +"""Live Core 0.15.3 checks for the Python SDK. + +Requires Blnk Core at http://localhost:5001 (or BLNK_BASE_URL). +Skipped unless BLNK_E2E=1. +""" + +from __future__ import annotations + +import os +import time + +import pytest + +from blnk_sdk import BlnkErrorCode, BlnkClientOptions, blnk_init +from blnk_sdk.types.transactions import ( + BulkTransactionPreview, + TransactionPreview, + is_bulk_transaction_preview, + is_transaction_preview, +) +from tests.utils import ( + BASE_URL, + BLNK_API_KEY, + generate_random_numbers_with_prefix, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("BLNK_E2E") != "1", + reason="requires live Blnk Core at http://localhost:5001/ (set BLNK_E2E=1)", +) + +CLIENT = blnk_init(BLNK_API_KEY, BlnkClientOptions(base_url=BASE_URL)) + + +def test_core_0_15_3_sdk_patch() -> None: + health = CLIENT.system.health() + assert health.status == 200 + assert (health.data or {}).get("status") == "UP" + + indicator = f"@SdkPy{int(time.time() * 1000)}" + gl = CLIENT.ledger_balances.create( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": indicator, + } + ) + assert gl.status == 201, f"GL create: {gl.message}" + assert (gl.data or {}).get("indicator") == indicator + assert (gl.data or {}).get("ledger_id") == "general_ledger_id" + + dest = f"@SdkPyDest{int(time.time() * 1000)}" + preview = CLIENT.transactions.create( + { + "amount": 25, + "precision": 100, + "reference": generate_random_numbers_with_prefix("dryrun", 8), + "description": "SDK dry-run", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "dry_run": True, + } + ) + assert preview.status == 200, f"dry-run status: {preview.status}" + assert is_transaction_preview(preview.data) + typed_preview = TransactionPreview.from_dict(preview.data) + assert typed_preview.dry_run is True + assert typed_preview.would_apply is True + assert typed_preview.balances + assert "transaction_id" not in (preview.data or {}) + + # Reusing the same reference after a dry-run must still post. + reuse_reference = generate_random_numbers_with_prefix("reuse", 8) + reuse_preview = CLIENT.transactions.create( + { + "amount": 5, + "precision": 100, + "reference": reuse_reference, + "description": "reuse after dry-run", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "dry_run": True, + } + ) + assert reuse_preview.status == 200 + posted_reuse = CLIENT.transactions.create( + { + "amount": 5, + "precision": 100, + "reference": reuse_reference, + "description": "reuse after dry-run", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "skip_queue": True, + } + ) + assert posted_reuse.status == 201, f"reuse post: {posted_reuse.message}" + + bulk_preview = CLIENT.transactions.create_bulk( + { + "dry_run": True, + "transactions": [ + { + "amount": 10, + "precision": 100, + "reference": generate_random_numbers_with_prefix("bulk-a", 8), + "description": "bulk preview 1", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + }, + { + "amount": 15, + "precision": 100, + "reference": generate_random_numbers_with_prefix("bulk-b", 8), + "description": "bulk preview 2", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + }, + ], + } + ) + assert bulk_preview.status == 200, f"bulk dry-run: {bulk_preview.message}" + assert is_bulk_transaction_preview(bulk_preview.data) + typed_bulk = BulkTransactionPreview.from_dict(bulk_preview.data) + assert typed_bulk.dry_run is True + assert typed_bulk.would_apply is True + + posted = CLIENT.transactions.create( + { + "amount": 20, + "precision": 100, + "reference": generate_random_numbers_with_prefix("posted", 8), + "description": "SDK posted for refund", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "skip_queue": True, + } + ) + assert posted.status == 201, f"posted create: {posted.message}" + posted_id = (posted.data or {}).get("transaction_id") + assert posted_id + + refund_preview = CLIENT.transactions.refund(posted_id, {"dry_run": True}) + assert refund_preview.status == 200, f"refund dry-run: {refund_preview.message}" + assert (refund_preview.data or {}).get("dry_run") is True + + refunded = CLIENT.transactions.refund( + posted_id, + { + "skip_queue": True, + "description": "SDK refund narration", + "meta_data": {"channel": "sdk-test"}, + }, + ) + assert refunded.status == 201, f"refund: {refunded.message}" + assert (refunded.data or {}).get("description") == "SDK refund narration" + refund_meta = (refunded.data or {}).get("meta_data") or {} + assert refund_meta.get("channel") == "sdk-test" + + hold = CLIENT.transactions.create( + { + "amount": 30, + "precision": 100, + "reference": generate_random_numbers_with_prefix("hold", 8), + "description": "SDK inflight hold", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "inflight": True, + "skip_queue": True, + } + ) + assert hold.status == 201, f"inflight create: {hold.message}" + hold_id = (hold.data or {}).get("transaction_id") + assert hold_id + + inflight_preview = CLIENT.transactions.update_status( + hold_id, {"status": "commit", "dry_run": True} + ) + assert inflight_preview.status == 200, f"inflight dry-run: {inflight_preview.message}" + assert (inflight_preview.data or {}).get("dry_run") is True + assert (inflight_preview.data or {}).get("operation") == "commit" + + after_inflight_preview = CLIENT.transactions.get(hold_id) + assert (after_inflight_preview.data or {}).get("status") == "INFLIGHT" + + commit_preview = CLIENT.transactions.bulk_commit_inflight( + { + "dry_run": True, + "skip_queue": True, + "transactions": [{"transaction_id": hold_id}], + } + ) + assert commit_preview.status == 200, f"bulk commit dry-run: {commit_preview.message}" + assert (commit_preview.data or {}).get("dry_run") is True + assert (commit_preview.data or {}).get("would_apply") is True + assert (commit_preview.data or {}).get("cumulative") is False + + after_commit_preview = CLIENT.transactions.get(hold_id) + assert (after_commit_preview.data or {}).get("status") == "INFLIGHT" + + void_preview = CLIENT.transactions.bulk_void_inflight( + { + "dry_run": True, + "skip_queue": True, + "transaction_ids": [hold_id], + } + ) + assert void_preview.status == 200, f"bulk void dry-run: {void_preview.message}" + assert is_bulk_transaction_preview(void_preview.data) + typed_void = BulkTransactionPreview.from_dict(void_preview.data) + assert typed_void.dry_run is True + assert typed_void.would_apply is True + if typed_void.results: + assert typed_void.results[0].operation == "void" + + after_void_preview = CLIENT.transactions.get(hold_id) + assert (after_void_preview.data or {}).get("status") == "INFLIGHT" + + hook_ids: list[str] = [] + for hook_type in ("PRE_TRANSACTION", "POST_TRANSACTION"): + created_hook = CLIENT.hooks.create( + { + "name": f"sdk-{hook_type.lower()}-{int(time.time() * 1000)}", + "url": "https://example.com/blnk-sdk-hook", + "type": hook_type, + "active": False, + "timeout": 5, + "retry_count": 0, + } + ) + if created_hook.status in (200, 201) and (created_hook.data or {}).get("id"): + hook_ids.append(created_hook.data["id"]) + + try: + hooks = CLIENT.hooks.list() + assert hooks.status in (200, 403), f"hooks list: {hooks.status}" + if hooks.status == 200: + listed = hooks.data if isinstance(hooks.data, list) else [] + listed_types = { + item.get("type") for item in listed if isinstance(item, dict) + } + if hook_ids: + listed_ids = { + item.get("id") for item in listed if isinstance(item, dict) + } + assert set(hook_ids) <= listed_ids + assert "PRE_TRANSACTION" in listed_types + assert "POST_TRANSACTION" in listed_types + finally: + for hook_id in hook_ids: + CLIENT.hooks.delete(hook_id) + + negative = CLIENT.transactions.create( + { + "amount": -1, + "precision": 100, + "reference": generate_random_numbers_with_prefix("neg", 8), + "description": "invalid amount", + "currency": "USD", + "source": indicator, + "destination": dest, + "allow_overdraft": True, + "skip_queue": True, + } + ) + assert negative.status != 201 + assert negative.error is not None + assert negative.error.code == BlnkErrorCode.TXN_VALIDATION_ERROR + + same_balance = CLIENT.transactions.create( + { + "amount": 10, + "precision": 100, + "reference": generate_random_numbers_with_prefix("same", 8), + "description": "same source dest", + "currency": "USD", + "source": indicator, + "destination": indicator, + "allow_overdraft": True, + "skip_queue": True, + } + ) + assert same_balance.status != 201 + assert same_balance.error is not None + assert same_balance.error.code == BlnkErrorCode.TXN_VALIDATION_ERROR + + duplicate = CLIENT.ledger_balances.create( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": indicator, + } + ) + assert duplicate.status == 409 + assert duplicate.error is not None + assert duplicate.error.code == BlnkErrorCode.GEN_CONFLICT diff --git a/tests/unit/endpoints/test_ledger_balances.py b/tests/unit/endpoints/test_ledger_balances.py index 161b401..26529e6 100644 --- a/tests/unit/endpoints/test_ledger_balances.py +++ b/tests/unit/endpoints/test_ledger_balances.py @@ -175,6 +175,57 @@ def test_create_forwards_track_fund_lineage_and_allocation_strategy() -> None: assert response.status == 201 +def test_create_forwards_general_ledger_indicator() -> None: + """create forwards indicator when ledger_id is general_ledger_id""" + third_party_request = create_mock_blnk_request(True, None, 201) + captured_request = CapturingRequest(third_party_request) + ledger_balance = LedgerBalances(captured_request, MOCK_LOGGER, format_response) + + data = { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "@Revenue", + } + + response = ledger_balance.create(data) + + assert captured_request.calls == [ + ( + "balances", + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "@Revenue", + }, + "POST", + None, + ) + ] + assert response.status == 201 + + +def test_create_rejects_indicator_on_non_general_ledger() -> None: + """create rejects indicator unless ledger_id is general_ledger_id""" + third_party_request = create_mock_blnk_request(True) + captured_request = CapturingRequest(third_party_request) + ledger_balance = LedgerBalances(captured_request, MOCK_LOGGER, format_response) + + response = ledger_balance.create( + { + "ledger_id": LEDGER_ID, + "currency": "USD", + "indicator": "@Revenue", + } + ) + + assert captured_request.calls == [] + assert response.status == 400 + assert ( + response.message + == "indicator is only valid when ledger_id is general_ledger_id" + ) + + def test_create_rejects_invalid_allocation_strategy() -> None: """create rejects invalid allocation_strategy""" third_party_request = create_mock_blnk_request(True) diff --git a/tests/unit/endpoints/test_transactions.py b/tests/unit/endpoints/test_transactions.py index 5d83ebf..5776720 100644 --- a/tests/unit/endpoints/test_transactions.py +++ b/tests/unit/endpoints/test_transactions.py @@ -167,6 +167,24 @@ def test_rejects_invalid_atomic_on_create(): assert response.message == "atomic must be a boolean if provided." +def test_creates_a_transaction_with_dry_run(): + """Creates a transaction > forwards dry_run on create""" + service, captured = _make(200) + data = { + "amount": 10000, + "currency": "USD", + "description": "Dry-run preview", + "precision": 100, + "reference": "dry_run_ref_001", + "source": "@FundingPool", + "destination": "bln_recipient", + "dry_run": True, + } + transaction = service.create(data) + assert _args(captured) == [("transactions", data, "POST")] + assert transaction.status == 200 + + def test_returns_core_create_response_fields(): """Creates a transaction > returns Core create response fields""" core_response = core_create_transaction_reference_response @@ -354,6 +372,15 @@ def test_updatestatus_rejects_invalid_skip_queue(): assert "skip_queue must be a boolean if provided" in transaction.message +def test_updatestatus_forwards_dry_run_on_request(): + """Updates a transaction > updateStatus forwards dry_run on request""" + service, captured = _make(200) + data = {"status": "commit", "dry_run": True} + transaction = service.update_status("1234", data) + assert _args(captured) == [("transactions/inflight/1234", data, "PUT")] + assert transaction.status == 200 + + # --------------------------------------------------------------------------- # Block: GET transaction by id (mock success=True, status=200) # --------------------------------------------------------------------------- @@ -502,6 +529,21 @@ def test_refund_rejects_invalid_skip_queue(): assert refund_response.message == "skip_queue must be a boolean if provided." +def test_refund_forwards_description_meta_data_and_dry_run(): + """Refunds a transaction > refund forwards description, meta_data, and dry_run""" + service, captured = _make(200) + options = { + "description": "Card refund", + "meta_data": {"reason": "chargeback"}, + "dry_run": True, + } + refund_response = service.refund("txn_refund_1234", options) + assert _args(captured) == [ + ("refund-transaction/txn_refund_1234", options, "POST") + ] + assert refund_response.status == 200 + + # --------------------------------------------------------------------------- # Block: Creates bulk transactions (mock success=True, status=201) # --------------------------------------------------------------------------- @@ -775,6 +817,28 @@ def test_should_handle_bulk_transactions_with_multiple_sources(): assert bulk_response.status == 201 +def test_createbulk_forwards_dry_run_on_bulk_request(): + """Creates bulk transactions > createBulk forwards dry_run on bulk request""" + service, captured = _make(200) + data = { + "dry_run": True, + "transactions": [ + { + "amount": 100, + "currency": "USD", + "description": "Bulk dry-run", + "precision": 100, + "reference": "bulk_dry_001", + "source": "@FundingPool", + "destination": "@Recipient", + } + ], + } + response = service.create_bulk(data) + assert _args(captured) == [("transactions/bulk", data, "POST")] + assert response.status == 200 + + def test_createbulk_forwards_skip_queue_on_bulk_request(): """Creates bulk transactions > createBulk forwards skip_queue on bulk request""" service, captured = _make(201) @@ -886,6 +950,20 @@ def test_bulkcommitinflight_rejects_missing_transaction_id(): assert response.message == "transaction_id is required at index 0." +def test_bulkcommitinflight_forwards_dry_run_on_request(): + """bulkCommitInflight > bulkCommitInflight forwards dry_run on request""" + service, captured = _make(200) + data = { + "dry_run": True, + "transactions": [ + {"transaction_id": "txn_11111111-1111-4111-8111-111111111111"}, + ], + } + response = service.bulk_commit_inflight(data) + assert _args(captured) == [("transactions/inflight/bulk/commit", data, "POST")] + assert response.status == 200 + + def test_bulkcommitinflight_forwards_skip_queue_on_request(): """bulkCommitInflight > bulkCommitInflight forwards skip_queue on request""" service, captured = _make(200) @@ -949,6 +1027,18 @@ def test_bulkvoidinflight_rejects_missing_transaction_id(): assert response.message == "transaction_id is required at index 0." +def test_bulkvoidinflight_forwards_dry_run_on_request(): + """bulkVoidInflight > bulkVoidInflight forwards dry_run on request""" + service, captured = _make(200) + data = { + "dry_run": True, + "transaction_ids": ["txn_11111111-1111-4111-8111-111111111111"], + } + response = service.bulk_void_inflight(data) + assert _args(captured) == [("transactions/inflight/bulk/void", data, "POST")] + assert response.status == 200 + + def test_bulkvoidinflight_forwards_skip_queue_on_request(): """bulkVoidInflight > bulkVoidInflight forwards skip_queue on request""" service, captured = _make(200) diff --git a/tests/unit/types/test_dry_run_preview.py b/tests/unit/types/test_dry_run_preview.py new file mode 100644 index 0000000..ba358c6 --- /dev/null +++ b/tests/unit/types/test_dry_run_preview.py @@ -0,0 +1,131 @@ +"""Unit tests for Core 0.15.3 dry-run preview types.""" + +from __future__ import annotations + +from blnk_sdk.types.transactions import ( + BulkTransactionPreview, + TransactionPreview, + is_bulk_transaction_preview, + is_transaction_preview, +) + + +def test_accepts_a_create_dry_run_preview(): + preview = TransactionPreview.from_dict( + { + "dry_run": True, + "would_apply": True, + "status": "APPLIED", + "reference": "ref_card_settle_4821", + "currency": "USD", + "amount": 120, + "precise_amount": "12000", + "precision": 100, + "balances": [ + { + "balance_id": "bln_source", + "role": "source", + "currency": "USD", + "current_balance": "50000", + "resulting_balance": "38000", + "virtual": False, + }, + { + "balance_id": "bln_dest", + "role": "destination", + "currency": "USD", + "current_balance": "0", + "resulting_balance": "12000", + }, + ], + } + ) + + assert preview.dry_run is True + assert preview.would_apply is True + assert preview.balances[0].resulting_balance == "38000" + assert preview.balances[0].virtual is False + assert is_transaction_preview(preview) + assert not is_bulk_transaction_preview(preview) + + +def test_accepts_a_rejected_preview(): + preview = TransactionPreview.from_dict( + { + "dry_run": True, + "would_apply": False, + "rejection": { + "code": "TXN_INSUFFICIENT_FUNDS", + "reason": "insufficient_funds", + "message": "insufficient funds in source balance", + }, + "currency": "USD", + "amount": 120, + "precise_amount": "12000", + "precision": 100, + "balances": [], + } + ) + + assert preview.would_apply is False + assert preview.rejection is not None + assert preview.rejection.code == "TXN_INSUFFICIENT_FUNDS" + assert preview.status is None + + +def test_accepts_an_inflight_preview_with_operation(): + preview = TransactionPreview.from_dict( + { + "dry_run": True, + "would_apply": True, + "operation": "commit", + "status": "APPLIED", + "currency": "USD", + "amount": 50, + "precise_amount": "5000", + "precision": 100, + "balances": [], + } + ) + + assert preview.operation == "commit" + + +def test_accepts_a_bulk_dry_run_preview(): + preview = BulkTransactionPreview.from_dict( + { + "dry_run": True, + "would_apply": True, + "cumulative": False, + "atomic": False, + "results": [ + { + "dry_run": True, + "would_apply": True, + "operation": "commit", + "currency": "USD", + "amount": 50, + "precise_amount": "5000", + "precision": 100, + "balances": [], + } + ], + } + ) + + assert preview.dry_run is True + assert preview.cumulative is False + assert preview.results[0].operation == "commit" + assert is_bulk_transaction_preview(preview) + assert is_bulk_transaction_preview(preview.to_dict()) + assert not is_transaction_preview(preview.to_dict()) + + +def test_discriminators_treat_posted_transaction_as_not_a_preview(): + posted = { + "transaction_id": "txn_123", + "reference": "ref_001", + "status": "APPLIED", + } + assert not is_transaction_preview(posted) + assert not is_bulk_transaction_preview(posted) diff --git a/tests/unit/utils/test_api_errors.py b/tests/unit/utils/test_api_errors.py index 17efcdc..869fe37 100644 --- a/tests/unit/utils/test_api_errors.py +++ b/tests/unit/utils/test_api_errors.py @@ -2,7 +2,11 @@ from __future__ import annotations -from blnk_sdk.errors import BlnkApiErrorDetail, parse_blnk_api_error_body +from blnk_sdk.errors import ( + BlnkApiErrorDetail, + BlnkErrorCode, + parse_blnk_api_error_body, +) def test_parses_error_detail_from_core_api_responses() -> None: @@ -32,3 +36,27 @@ def test_falls_back_to_legacy_error_string() -> None: def test_returns_null_for_non_object_bodies() -> None: assert parse_blnk_api_error_body(None) is None assert parse_blnk_api_error_body("oops") is None + + +def test_surfaces_core_0_15_3_codes_callers_should_recognize() -> None: + cases = [ + { + "code": BlnkErrorCode.TXN_INVALID_AMOUNT, + "message": "precise_amount must be positive", + }, + { + "code": BlnkErrorCode.TXN_VALIDATION_ERROR, + "message": "source and destination cannot be the same", + }, + { + "code": BlnkErrorCode.GEN_CONFLICT, + "message": "internal balance already exists", + }, + ] + for case in cases: + parsed = parse_blnk_api_error_body( + {"error_detail": {"code": case["code"], "message": case["message"]}} + ) + assert parsed == BlnkApiErrorDetail( + code=case["code"], message=case["message"] + ) diff --git a/tests/unit/validators/test_ledger_balance_validators.py b/tests/unit/validators/test_ledger_balance_validators.py index f641a47..5bc85c4 100644 --- a/tests/unit/validators/test_ledger_balance_validators.py +++ b/tests/unit/validators/test_ledger_balance_validators.py @@ -137,6 +137,76 @@ def test_create_ledger_balance_rejects_non_boolean_track_fund_lineage() -> None: ) +def test_create_ledger_balance_accepts_general_ledger_indicator() -> None: + """ValidateCreateLedgerBalance > accepts indicator on general_ledger_id""" + assert ( + validate_create_ledger_balance( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "@Revenue", + } + ) + is None + ) + + +def test_create_ledger_balance_rejects_indicator_without_at() -> None: + """ValidateCreateLedgerBalance > rejects indicator that does not start with @""" + assert ( + validate_create_ledger_balance( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "Revenue", + } + ) + == "indicator must start with @" + ) + + +def test_create_ledger_balance_rejects_indicator_with_spaces() -> None: + """ValidateCreateLedgerBalance > rejects indicator with spaces""" + assert ( + validate_create_ledger_balance( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "@Rev enue", + } + ) + == "indicator must not contain spaces" + ) + + +def test_create_ledger_balance_rejects_indicator_on_non_gl_ledger() -> None: + """ValidateCreateLedgerBalance > rejects indicator unless ledger_id is general_ledger_id""" + assert ( + validate_create_ledger_balance( + { + "ledger_id": "ldg_123", + "currency": "USD", + "indicator": "@Revenue", + } + ) + == "indicator is only valid when ledger_id is general_ledger_id" + ) + + +def test_create_ledger_balance_rejects_empty_indicator() -> None: + """ValidateCreateLedgerBalance > rejects empty indicator""" + assert ( + validate_create_ledger_balance( + { + "ledger_id": "general_ledger_id", + "currency": "USD", + "indicator": "", + } + ) + == "indicator must be a non-empty string if provided" + ) + + def test_create_ledger_balance_rejects_invalid_allocation_strategy() -> None: """ValidateCreateLedgerBalance lineage fields > rejects invalid allocation_strategy""" diff --git a/tests/unit/validators/test_transaction_validators.py b/tests/unit/validators/test_transaction_validators.py index 065b287..1e22218 100644 --- a/tests/unit/validators/test_transaction_validators.py +++ b/tests/unit/validators/test_transaction_validators.py @@ -384,6 +384,33 @@ def test_allows_skip_queue_on_create_payloads(self): assert validate_create_transactions(data) is None + def test_allows_dry_run_on_create_payloads(self): + """allows dry_run on create payloads""" + data = { + **BASE_FIELDS, + "amount": 1000, + "source": "@FundingPool", + "destination": "@Recipient", + "dry_run": True, + } + + assert validate_create_transactions(data) is None + + def test_rejects_invalid_dry_run_on_create_payloads(self): + """rejects invalid dry_run on create payloads""" + data = { + **BASE_FIELDS, + "amount": 1000, + "source": "@FundingPool", + "destination": "@Recipient", + "dry_run": "true", + } + + assert ( + validate_create_transactions(data) + == "dry_run must be a boolean if provided." + ) + def test_allows_effective_date_as_an_iso_string(self): """allows effective_date as an ISO string""" data = { @@ -742,6 +769,41 @@ def test_allows_skip_queue_on_bulk_payloads(self): assert validate_bulk_transactions(data) is None + def test_allows_dry_run_on_bulk_payloads(self): + """allows dry_run on bulk payloads""" + data = { + "dry_run": True, + "transactions": [ + { + **BASE_FIELDS, + "amount": 100, + "reference": "bulk_dry_1", + "source": "@FundingPool", + "destination": "@Recipient", + } + ], + } + assert validate_bulk_transactions(data) is None + + def test_rejects_invalid_dry_run_on_bulk_payloads(self): + """rejects invalid dry_run on bulk payloads""" + data = { + "dry_run": "true", + "transactions": [ + { + **BASE_FIELDS, + "amount": 100, + "reference": "bulk_dry_bad", + "source": "@FundingPool", + "destination": "@Recipient", + } + ], + } + assert ( + validate_bulk_transactions(data) + == "dry_run must be a boolean if provided." + ) + def test_rejects_invalid_skip_queue_on_bulk_payloads(self): """rejects invalid skip_queue on bulk payloads""" data = { @@ -840,6 +902,27 @@ def test_allows_skip_queue_on_update_payloads(self): assert validate_update_transactions(data) is None + def test_allows_dry_run_on_update_payloads(self): + """allows dry_run on update payloads""" + data = { + "status": "commit", + "dry_run": True, + } + + assert validate_update_transactions(data) is None + + def test_rejects_invalid_dry_run_on_update_payloads(self): + """rejects invalid dry_run on update payloads""" + data = { + "status": "commit", + "dry_run": "true", + } + + assert ( + validate_update_transactions(data) + == "dry_run must be a boolean if provided." + ) + def test_rejects_invalid_skip_queue_on_update_payloads(self): """rejects invalid skip_queue on update payloads""" data = { @@ -883,6 +966,37 @@ def test_rejects_unknown_fields_on_refund_payloads(self): assert validate_refund_transaction(data) == "Invalid field: amount" + def test_allows_description_meta_data_and_dry_run_on_refund(self): + """allows description, meta_data, and dry_run on refund payloads""" + data = { + "skip_queue": True, + "description": "Card refund", + "meta_data": {"reason": "chargeback"}, + "dry_run": True, + } + assert validate_refund_transaction(data) is None + + def test_rejects_invalid_description_on_refund(self): + """rejects invalid description on refund payloads""" + assert ( + validate_refund_transaction({"description": 12}) + == "description must be a string if provided." + ) + + def test_rejects_invalid_meta_data_on_refund(self): + """rejects invalid meta_data on refund payloads""" + assert ( + validate_refund_transaction({"meta_data": "not-an-object"}) + == "meta_data must be a valid object if provided" + ) + + def test_rejects_invalid_dry_run_on_refund(self): + """rejects invalid dry_run on refund payloads""" + assert ( + validate_refund_transaction({"dry_run": "true"}) + == "dry_run must be a boolean if provided." + ) + class TestBulkCommitInflightValidation: """bulkCommitInflight validation""" @@ -962,6 +1076,31 @@ def test_rejects_invalid_skip_queue_on_bulk_commit_payloads(self): == "skip_queue must be a boolean if provided." ) + def test_allows_dry_run_on_bulk_commit_payloads(self): + """allows dry_run on bulk commit payloads""" + data = { + "dry_run": True, + "transactions": [ + {"transaction_id": "txn_11111111-1111-4111-8111-111111111111"} + ], + } + + assert validate_bulk_commit_inflight(data) is None + + def test_rejects_invalid_dry_run_on_bulk_commit_payloads(self): + """rejects invalid dry_run on bulk commit payloads""" + data = { + "dry_run": "true", + "transactions": [ + {"transaction_id": "txn_11111111-1111-4111-8111-111111111111"} + ], + } + + assert ( + validate_bulk_commit_inflight(data) + == "dry_run must be a boolean if provided." + ) + class TestBulkVoidInflightValidation: """bulkVoidInflight validation""" @@ -1021,6 +1160,27 @@ def test_rejects_invalid_skip_queue_on_bulk_void_payloads(self): == "skip_queue must be a boolean if provided." ) + def test_allows_dry_run_on_bulk_void_payloads(self): + """allows dry_run on bulk void payloads""" + data = { + "dry_run": True, + "transaction_ids": ["txn_11111111-1111-4111-8111-111111111111"], + } + + assert validate_bulk_void_inflight(data) is None + + def test_rejects_invalid_dry_run_on_bulk_void_payloads(self): + """rejects invalid dry_run on bulk void payloads""" + data = { + "dry_run": "true", + "transaction_ids": ["txn_11111111-1111-4111-8111-111111111111"], + } + + assert ( + validate_bulk_void_inflight(data) + == "dry_run must be a boolean if provided." + ) + class TestRecoverQueue: """recoverQueue"""