Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
46 changes: 46 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion blnk_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
)
from .errors import (
BlnkApiErrorDetail,
BlnkErrorCode,
BlnkServiceError,
BlnkTimeoutError,
parse_blnk_api_error_body,
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions blnk_sdk/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
12 changes: 8 additions & 4 deletions blnk_sdk/services/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion blnk_sdk/services/ledger_balances.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 29 additions & 6 deletions blnk_sdk/services/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion blnk_sdk/types/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions blnk_sdk/types/ledger_balances.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading