tinker-payments 0.3.0__tar.gz

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.
Files changed (34) hide show
  1. tinker_payments-0.3.0/.env.example +2 -0
  2. tinker_payments-0.3.0/.github/workflows/ci.yml +32 -0
  3. tinker_payments-0.3.0/.gitignore +4 -0
  4. tinker_payments-0.3.0/LICENSE +21 -0
  5. tinker_payments-0.3.0/PKG-INFO +194 -0
  6. tinker_payments-0.3.0/README.md +180 -0
  7. tinker_payments-0.3.0/pyproject.toml +30 -0
  8. tinker_payments-0.3.0/requirements.txt +1 -0
  9. tinker_payments-0.3.0/tests/fixtures/checkout-cancellation-webhook.json +1 -0
  10. tinker_payments-0.3.0/tests/fixtures/merchant-lifecycle.README.md +1 -0
  11. tinker_payments-0.3.0/tests/fixtures/merchant-lifecycle.json +436 -0
  12. tinker_payments-0.3.0/tests/fixtures/refund-webhook.json +1 -0
  13. tinker_payments-0.3.0/tests/fixtures/setup-webhook.json +1 -0
  14. tinker_payments-0.3.0/tests/fixtures/withdrawal-webhook.json +1 -0
  15. tinker_payments-0.3.0/tests/test_lifecycle.py +186 -0
  16. tinker_payments-0.3.0/tests/test_managed_billing.py +179 -0
  17. tinker_payments-0.3.0/tests/test_onboarding_webhook.py +26 -0
  18. tinker_payments-0.3.0/tests/test_sdk.py +113 -0
  19. tinker_payments-0.3.0/tests/test_webhook.py +52 -0
  20. tinker_payments-0.3.0/tinker/__init__.py +13 -0
  21. tinker_payments-0.3.0/tinker/__version__.py +1 -0
  22. tinker_payments-0.3.0/tinker/api.py +339 -0
  23. tinker_payments-0.3.0/tinker/auth.py +107 -0
  24. tinker_payments-0.3.0/tinker/client.py +76 -0
  25. tinker_payments-0.3.0/tinker/client.pyi +24 -0
  26. tinker_payments-0.3.0/tinker/configuration.py +45 -0
  27. tinker_payments-0.3.0/tinker/endpoints.py +10 -0
  28. tinker_payments-0.3.0/tinker/exceptions.py +42 -0
  29. tinker_payments-0.3.0/tinker/lifecycle.py +235 -0
  30. tinker_payments-0.3.0/tinker/lifecycle_helpers.py +20 -0
  31. tinker_payments-0.3.0/tinker/managed_checkout.py +20 -0
  32. tinker_payments-0.3.0/tinker/models.py +62 -0
  33. tinker_payments-0.3.0/tinker/py.typed +0 -0
  34. tinker_payments-0.3.0/tinker/webhook.py +377 -0
@@ -0,0 +1,2 @@
1
+ TINKER_API_KEY= # Obtained from the app dashboard on tinker
2
+ TINKER_API_SECRET= # Obtained from the app dashboard on tinker
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ lint-and-test:
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 10
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python-version: ['3.9', '3.11', '3.13', '3.14']
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ cache: pip
29
+ cache-dependency-path: pyproject.toml
30
+ - run: python -m pip install '.[dev]'
31
+ - run: ruff check .
32
+ - run: python -m unittest discover -s tests -v
@@ -0,0 +1,4 @@
1
+ .env
2
+ .venv/
3
+ *__pycache__/
4
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tinker Digital Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.5
2
+ Name: tinker-payments
3
+ Version: 0.3.0
4
+ Summary: Python SDK for the standardized Tinker Payments API
5
+ Project-URL: Homepage, https://github.com/Tinker-Digital-Ltd/tinker-payments-py-sdk
6
+ Author-email: Tinker Digital LTD <create@tinker.co.ke>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: requests>=2.32.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: ruff==0.14.1; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Tinker Payments Python SDK
16
+
17
+ Python 3.9+ SDK with automatic authentication, payments, subscriptions and signed webhooks. Keep secret keys on the server.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install tinker-payments
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ from tinker import TinkerClient
29
+ from tinker.exceptions import ApiError
30
+
31
+ client = TinkerClient("pk_test_xxx", "sk_test_xxx")
32
+
33
+ payment = client.transactions().initiate(
34
+ {
35
+ "amount": 1200,
36
+ "currency": "KES",
37
+ "gateway": "mpesa",
38
+ "merchantReference": "ORDER-1001",
39
+ "returnUrl": "https://merchant.example/payment/return",
40
+ "customerPhone": "+254712345678",
41
+ }
42
+ )
43
+
44
+ result = client.transactions().query(
45
+ {
46
+ "reference": "TP-REF-123",
47
+ "gateway": "mpesa",
48
+ }
49
+ )
50
+ ```
51
+
52
+ Payments use currency units. Mpesa requires `customerPhone`; `returnUrl` is the browser destination, while webhooks are configured in the dashboard.
53
+
54
+ ## Configuration
55
+
56
+ Test key prefixes (`pk_test_` or `sk_test_`) select `https://sandbox-api.tinkerpayments.com/v1/`; other keys select `https://api.tinkerpayments.com/v1/`. Override with `TinkerClient(pk, sk, base_url="https://custom-host/v1")`; pass `session=` for a requests-compatible transport.
57
+
58
+ ## Plan subscriptions
59
+
60
+ ```python
61
+ subs = client.subscriptions()
62
+
63
+ subs.create_plan(
64
+ {
65
+ "name": "Pro",
66
+ "amount": 1000,
67
+ "currency": "KES",
68
+ "intervals": ["monthly"],
69
+ "description": "Pro plan",
70
+ "is_active": True,
71
+ }
72
+ )
73
+
74
+ subs.list_plans()
75
+ subs.create(
76
+ {
77
+ "plan_id": "plan_123",
78
+ "gateway": "stripe",
79
+ "customer": {
80
+ "external_customer_id": "cust_123",
81
+ "email": "customer@example.com",
82
+ },
83
+ }
84
+ )
85
+ subs.list(plan_id="plan_123", external_customer_id="cust_123")
86
+ subs.cancel("sub_123")
87
+ ```
88
+
89
+ ## Managed recurring billing
90
+
91
+ Checkout uses exact integer minor-unit prices (`15000` = EUR 150.00), a `month` or `year` interval, and a quantity. The SDK does not convert prices or apply discounts. Responses are unwrapped data maps/objects.
92
+
93
+ | Method | Purpose |
94
+ |---|---|
95
+ | `checkout` | Create an exact-price recurring checkout |
96
+ | `billing_resource` | Read a managed billing resource |
97
+ | `change_quantity` | Change the current quantity |
98
+ | `schedule_quantity` | Schedule a quantity change |
99
+ | `clear_scheduled_quantity` | Remove a scheduled quantity change |
100
+ | `set_cancel_at_renewal` | Set cancellation at renewal; pass false to clear it |
101
+ | `billing_operation` | Recover the durable result by idempotency key |
102
+ | `payment_method_portal` | Create a short-lived hosted payment-method link |
103
+ | `billing_payment_history` | Read one page of captured payments and adjustments |
104
+
105
+ ```python
106
+ from tinker import ManagedCheckout
107
+
108
+ billing = client.subscriptions()
109
+ billing.checkout(ManagedCheckout(
110
+ external_customer_id="workspace-123", email="buyer@example.com", name="Buyer",
111
+ currency="EUR", unit_amount=15000, quantity=3, interval="year",
112
+ return_url="https://example.com/billing", idempotency_key="checkout-123",
113
+ ))
114
+ billing.change_quantity("resource-id", 4, "quantity-123")
115
+ billing.set_cancel_at_renewal("resource-id", False, "resume-123")
116
+ billing.payment_method_portal("resource-id", "https://example.com/billing", "portal-123")
117
+ page = billing.billing_payment_history("resource-id", "", 0)
118
+ # After an uncertain command response, recover using the SAME key:
119
+ operation = billing.billing_operation("quantity-123")
120
+ ```
121
+
122
+ Pass integers for minor-unit amounts, quantities and snapshots; do not convert through
123
+ floating-point prices. `ManagedCheckout` is an exported dataclass.
124
+
125
+ Use a stable key per mutation and reuse it with the same payload after a lost response. Recover through the operation lookup; missing or pending results remain uncertain. Only an explicit `outcome` of `not_applied` confirms no mutation.
126
+
127
+ The payment-method portal changes payment details only; quantity, price and overdue invoice payment are separate actions. It requires an app-owned managed subscription with a dedicated customer. Reuse the operation key after a lost response and use a new key for a deliberately new session after expiry.
128
+
129
+ For payment history, keep the returned `snapshot` across `next_cursor` pages until `has_more` is false. Amounts are integer minor units and timestamps are UTC RFC3339. History covers captured cash, tax-exclusive eligible amounts, refunds, and dispute principal withdrawals/reinstatements (`reverses_id` identifies the reversed withdrawal).
130
+
131
+ Quarantine items with `reconciliation_reason` and verify every page before committing a scan. The snapshot is an event cutoff, not an immutable provider snapshot: rescan from the first page to discover later adjustments, then apply facts idempotently by payment/adjustment ID. Zero-cash invoices advance pagination without an item. Processing/dispute fees are excluded from eligible principal; currency conversion and customer credits need explicit reconciliation.
132
+
133
+ ## Refunds, wallet withdrawals and checkout cancellation
134
+
135
+ The app owner must enable withdrawal requests in app settings. A payout is a withdrawal from the merchant's wallet to its own active, verified payout method. Amounts use currency units: withdrawing JPY 100 with a fee of 2 reserves 100 and pays 98. Obtain a quote and pass its `fee_amount` as `expected_fee_amount`; a fee conflict includes a refreshed quote in the structured API error. No method accepts arbitrary recipient banking details.
136
+
137
+ Supply a stable `Idempotency-Key` for every refund, payout or cancellation request. Keys are trimmed and must be 1–255 UTF-8 bytes. Reuse the same key and semantic request after transport failures; a changed request conflicts. A replay returns the original resource and fee snapshot. Start a new key for a new intended operation.
138
+
139
+ A refund's `pending_review`/`approved` state and a payout's `pending_approval`/`approved` state are not completion. Only `refunded`, `paid` or provider-confirmed `cancelled` establishes completion. Preserve cancellation `outcome_unknown` and poll by ID; do not blindly submit another request. Unsupported cancellation raises HTTP 422 `CHECKOUT_CANCELLATION_UNSUPPORTED` and retains the persisted `request_id`/`status` in error details. Lifecycle conflicts use HTTP 409; replaying a persisted rejection retains its 422/409 result.
140
+
141
+ Refund history includes refundable amounts and `refund_capability`. Cancellation history includes `capability`, the newest 100 requests and `has_more`; retrieve older known requests by ID. Payout listing accepts `page`, `per_page` (maximum 100), `status` and `currency`. Reads are scoped to the app; payout reads show only requests attributed to that app.
142
+
143
+ ```python
144
+ tx = client.transactions()
145
+ review = tx.refund(payment_id, {"reason": "requested_by_customer", "amount": 100}, "refund-order-42")
146
+ print(review["refund"]["status"])
147
+ payouts = client.payouts()
148
+ destinations = payouts.methods()
149
+ destination = next(method for method in destinations if method["status"] == "active" and method["is_verified"])
150
+ quote = payouts.quote({"payout_method_id": destination["id"], "amount": 100, "currency": destination["currency"]})
151
+ requested = payouts.request_payout({"payout_method_id": destination["id"], "amount": 100, "currency": destination["currency"], "expected_fee_amount": quote["fee_amount"]}, "withdrawal-42")
152
+ print(payouts.get(requested["withdrawal"]["id"])["status"])
153
+ try:
154
+ result = tx.cancel_checkout(unpaid_payment_id, {"reason": "Customer left checkout"}, "cancel-order-42")
155
+ print(tx.get_cancellation(unpaid_payment_id, result["cancellation"]["id"])["status"])
156
+ except ApiError as error: # from tinker.exceptions import ApiError
157
+ print(error.error_code, error.details)
158
+ ```
159
+
160
+ `transactions()` also exposes `refunds`, `get_refund` and `cancellations`; `payouts().list()` accepts typed pagination filters. The public `tinker.lifecycle` module provides TypedDict request/result/resource schemas and Literal status/reason types. Client stubs expose the new manager; `ApiError.details` retains the complete response envelope and nested `error.details`.
161
+
162
+ Cancellation resources expose `payment_conflict` when later successful payment evidence conflicts with expiry. Treat successful payment as authoritative and use the refund workflow where appropriate.
163
+
164
+ ## Webhooks
165
+
166
+ ```python
167
+ event = client.webhooks().handle(raw_body)
168
+ if not client.webhooks().verify_signature(event, webhook_secret):
169
+ raise ValueError("Invalid webhook signature")
170
+ transaction = event.to_transaction() # None for setup/lifecycle events
171
+ ```
172
+
173
+ Verify the signature before consuming fields or converting the event to a transaction; reuse the parsed event rather than reading the request body twice. Deduplicate by event ID. The SDK verifies HMAC-SHA256 over the envelope excluding `security`, using the server's field order, map ordering and escaping.
174
+
175
+ After verification, acknowledge `setup.webhook_test` (`source: onboarding`) with HTTP 200, 202 or 204; it needs no payment lookup or fulfillment. The signed lifecycle events `refund.updated.v1`, `withdrawal.updated.v1` and `checkout.cancellation.updated.v1` carry `data.resource` with `source: payment`. Their typed resources never convert into payment transactions. Poll request IDs if delivery is delayed.
176
+
177
+ ## Errors and response metadata
178
+
179
+ API errors retain `http_status`, `error_code`, `provider_code`, `request_id`, `retry_after`, `outcome` and the full envelope in `details`. Request IDs prefer the response body over `X-Request-ID`; `Retry-After` remains its original string. Non-JSON HTTP failures retain status/headers and an uncertain outcome. Only an explicit `not_applied` outcome confirms no mutation. Managers unwrap response `data`; use `get_last_meta()` for request metadata and `client.get_last_auth_meta()` for authentication metadata.
180
+
181
+ ```python
182
+ try:
183
+ client.transactions().query({"reference": "TP-REF-123", "gateway": "mpesa"})
184
+ except ApiError as error:
185
+ print(error.error_code, error.details)
186
+ ```
187
+
188
+ ## Development
189
+
190
+ ```sh
191
+ python -m pip install -e '.[dev]'
192
+ ruff check .
193
+ python -m unittest discover -s tests -v
194
+ ```
@@ -0,0 +1,180 @@
1
+ # Tinker Payments Python SDK
2
+
3
+ Python 3.9+ SDK with automatic authentication, payments, subscriptions and signed webhooks. Keep secret keys on the server.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install tinker-payments
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from tinker import TinkerClient
15
+ from tinker.exceptions import ApiError
16
+
17
+ client = TinkerClient("pk_test_xxx", "sk_test_xxx")
18
+
19
+ payment = client.transactions().initiate(
20
+ {
21
+ "amount": 1200,
22
+ "currency": "KES",
23
+ "gateway": "mpesa",
24
+ "merchantReference": "ORDER-1001",
25
+ "returnUrl": "https://merchant.example/payment/return",
26
+ "customerPhone": "+254712345678",
27
+ }
28
+ )
29
+
30
+ result = client.transactions().query(
31
+ {
32
+ "reference": "TP-REF-123",
33
+ "gateway": "mpesa",
34
+ }
35
+ )
36
+ ```
37
+
38
+ Payments use currency units. Mpesa requires `customerPhone`; `returnUrl` is the browser destination, while webhooks are configured in the dashboard.
39
+
40
+ ## Configuration
41
+
42
+ Test key prefixes (`pk_test_` or `sk_test_`) select `https://sandbox-api.tinkerpayments.com/v1/`; other keys select `https://api.tinkerpayments.com/v1/`. Override with `TinkerClient(pk, sk, base_url="https://custom-host/v1")`; pass `session=` for a requests-compatible transport.
43
+
44
+ ## Plan subscriptions
45
+
46
+ ```python
47
+ subs = client.subscriptions()
48
+
49
+ subs.create_plan(
50
+ {
51
+ "name": "Pro",
52
+ "amount": 1000,
53
+ "currency": "KES",
54
+ "intervals": ["monthly"],
55
+ "description": "Pro plan",
56
+ "is_active": True,
57
+ }
58
+ )
59
+
60
+ subs.list_plans()
61
+ subs.create(
62
+ {
63
+ "plan_id": "plan_123",
64
+ "gateway": "stripe",
65
+ "customer": {
66
+ "external_customer_id": "cust_123",
67
+ "email": "customer@example.com",
68
+ },
69
+ }
70
+ )
71
+ subs.list(plan_id="plan_123", external_customer_id="cust_123")
72
+ subs.cancel("sub_123")
73
+ ```
74
+
75
+ ## Managed recurring billing
76
+
77
+ Checkout uses exact integer minor-unit prices (`15000` = EUR 150.00), a `month` or `year` interval, and a quantity. The SDK does not convert prices or apply discounts. Responses are unwrapped data maps/objects.
78
+
79
+ | Method | Purpose |
80
+ |---|---|
81
+ | `checkout` | Create an exact-price recurring checkout |
82
+ | `billing_resource` | Read a managed billing resource |
83
+ | `change_quantity` | Change the current quantity |
84
+ | `schedule_quantity` | Schedule a quantity change |
85
+ | `clear_scheduled_quantity` | Remove a scheduled quantity change |
86
+ | `set_cancel_at_renewal` | Set cancellation at renewal; pass false to clear it |
87
+ | `billing_operation` | Recover the durable result by idempotency key |
88
+ | `payment_method_portal` | Create a short-lived hosted payment-method link |
89
+ | `billing_payment_history` | Read one page of captured payments and adjustments |
90
+
91
+ ```python
92
+ from tinker import ManagedCheckout
93
+
94
+ billing = client.subscriptions()
95
+ billing.checkout(ManagedCheckout(
96
+ external_customer_id="workspace-123", email="buyer@example.com", name="Buyer",
97
+ currency="EUR", unit_amount=15000, quantity=3, interval="year",
98
+ return_url="https://example.com/billing", idempotency_key="checkout-123",
99
+ ))
100
+ billing.change_quantity("resource-id", 4, "quantity-123")
101
+ billing.set_cancel_at_renewal("resource-id", False, "resume-123")
102
+ billing.payment_method_portal("resource-id", "https://example.com/billing", "portal-123")
103
+ page = billing.billing_payment_history("resource-id", "", 0)
104
+ # After an uncertain command response, recover using the SAME key:
105
+ operation = billing.billing_operation("quantity-123")
106
+ ```
107
+
108
+ Pass integers for minor-unit amounts, quantities and snapshots; do not convert through
109
+ floating-point prices. `ManagedCheckout` is an exported dataclass.
110
+
111
+ Use a stable key per mutation and reuse it with the same payload after a lost response. Recover through the operation lookup; missing or pending results remain uncertain. Only an explicit `outcome` of `not_applied` confirms no mutation.
112
+
113
+ The payment-method portal changes payment details only; quantity, price and overdue invoice payment are separate actions. It requires an app-owned managed subscription with a dedicated customer. Reuse the operation key after a lost response and use a new key for a deliberately new session after expiry.
114
+
115
+ For payment history, keep the returned `snapshot` across `next_cursor` pages until `has_more` is false. Amounts are integer minor units and timestamps are UTC RFC3339. History covers captured cash, tax-exclusive eligible amounts, refunds, and dispute principal withdrawals/reinstatements (`reverses_id` identifies the reversed withdrawal).
116
+
117
+ Quarantine items with `reconciliation_reason` and verify every page before committing a scan. The snapshot is an event cutoff, not an immutable provider snapshot: rescan from the first page to discover later adjustments, then apply facts idempotently by payment/adjustment ID. Zero-cash invoices advance pagination without an item. Processing/dispute fees are excluded from eligible principal; currency conversion and customer credits need explicit reconciliation.
118
+
119
+ ## Refunds, wallet withdrawals and checkout cancellation
120
+
121
+ The app owner must enable withdrawal requests in app settings. A payout is a withdrawal from the merchant's wallet to its own active, verified payout method. Amounts use currency units: withdrawing JPY 100 with a fee of 2 reserves 100 and pays 98. Obtain a quote and pass its `fee_amount` as `expected_fee_amount`; a fee conflict includes a refreshed quote in the structured API error. No method accepts arbitrary recipient banking details.
122
+
123
+ Supply a stable `Idempotency-Key` for every refund, payout or cancellation request. Keys are trimmed and must be 1–255 UTF-8 bytes. Reuse the same key and semantic request after transport failures; a changed request conflicts. A replay returns the original resource and fee snapshot. Start a new key for a new intended operation.
124
+
125
+ A refund's `pending_review`/`approved` state and a payout's `pending_approval`/`approved` state are not completion. Only `refunded`, `paid` or provider-confirmed `cancelled` establishes completion. Preserve cancellation `outcome_unknown` and poll by ID; do not blindly submit another request. Unsupported cancellation raises HTTP 422 `CHECKOUT_CANCELLATION_UNSUPPORTED` and retains the persisted `request_id`/`status` in error details. Lifecycle conflicts use HTTP 409; replaying a persisted rejection retains its 422/409 result.
126
+
127
+ Refund history includes refundable amounts and `refund_capability`. Cancellation history includes `capability`, the newest 100 requests and `has_more`; retrieve older known requests by ID. Payout listing accepts `page`, `per_page` (maximum 100), `status` and `currency`. Reads are scoped to the app; payout reads show only requests attributed to that app.
128
+
129
+ ```python
130
+ tx = client.transactions()
131
+ review = tx.refund(payment_id, {"reason": "requested_by_customer", "amount": 100}, "refund-order-42")
132
+ print(review["refund"]["status"])
133
+ payouts = client.payouts()
134
+ destinations = payouts.methods()
135
+ destination = next(method for method in destinations if method["status"] == "active" and method["is_verified"])
136
+ quote = payouts.quote({"payout_method_id": destination["id"], "amount": 100, "currency": destination["currency"]})
137
+ requested = payouts.request_payout({"payout_method_id": destination["id"], "amount": 100, "currency": destination["currency"], "expected_fee_amount": quote["fee_amount"]}, "withdrawal-42")
138
+ print(payouts.get(requested["withdrawal"]["id"])["status"])
139
+ try:
140
+ result = tx.cancel_checkout(unpaid_payment_id, {"reason": "Customer left checkout"}, "cancel-order-42")
141
+ print(tx.get_cancellation(unpaid_payment_id, result["cancellation"]["id"])["status"])
142
+ except ApiError as error: # from tinker.exceptions import ApiError
143
+ print(error.error_code, error.details)
144
+ ```
145
+
146
+ `transactions()` also exposes `refunds`, `get_refund` and `cancellations`; `payouts().list()` accepts typed pagination filters. The public `tinker.lifecycle` module provides TypedDict request/result/resource schemas and Literal status/reason types. Client stubs expose the new manager; `ApiError.details` retains the complete response envelope and nested `error.details`.
147
+
148
+ Cancellation resources expose `payment_conflict` when later successful payment evidence conflicts with expiry. Treat successful payment as authoritative and use the refund workflow where appropriate.
149
+
150
+ ## Webhooks
151
+
152
+ ```python
153
+ event = client.webhooks().handle(raw_body)
154
+ if not client.webhooks().verify_signature(event, webhook_secret):
155
+ raise ValueError("Invalid webhook signature")
156
+ transaction = event.to_transaction() # None for setup/lifecycle events
157
+ ```
158
+
159
+ Verify the signature before consuming fields or converting the event to a transaction; reuse the parsed event rather than reading the request body twice. Deduplicate by event ID. The SDK verifies HMAC-SHA256 over the envelope excluding `security`, using the server's field order, map ordering and escaping.
160
+
161
+ After verification, acknowledge `setup.webhook_test` (`source: onboarding`) with HTTP 200, 202 or 204; it needs no payment lookup or fulfillment. The signed lifecycle events `refund.updated.v1`, `withdrawal.updated.v1` and `checkout.cancellation.updated.v1` carry `data.resource` with `source: payment`. Their typed resources never convert into payment transactions. Poll request IDs if delivery is delayed.
162
+
163
+ ## Errors and response metadata
164
+
165
+ API errors retain `http_status`, `error_code`, `provider_code`, `request_id`, `retry_after`, `outcome` and the full envelope in `details`. Request IDs prefer the response body over `X-Request-ID`; `Retry-After` remains its original string. Non-JSON HTTP failures retain status/headers and an uncertain outcome. Only an explicit `not_applied` outcome confirms no mutation. Managers unwrap response `data`; use `get_last_meta()` for request metadata and `client.get_last_auth_meta()` for authentication metadata.
166
+
167
+ ```python
168
+ try:
169
+ client.transactions().query({"reference": "TP-REF-123", "gateway": "mpesa"})
170
+ except ApiError as error:
171
+ print(error.error_code, error.details)
172
+ ```
173
+
174
+ ## Development
175
+
176
+ ```sh
177
+ python -m pip install -e '.[dev]'
178
+ ruff check .
179
+ python -m unittest discover -s tests -v
180
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tinker-payments"
7
+ version = "0.3.0"
8
+ description = "Python SDK for the standardized Tinker Payments API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "Tinker Digital LTD", email = "create@tinker.co.ke" }
13
+ ]
14
+ requires-python = ">=3.9"
15
+ dependencies = [
16
+ "requests>=2.32.0"
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["ruff==0.14.1"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/Tinker-Digital-Ltd/tinker-payments-py-sdk"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["tinker"]
27
+ include = ["tinker/py.typed", "tinker/*.pyi", "tinker/**.pyi" ]
28
+
29
+ [tool.ruff]
30
+ target-version = "py39"
@@ -0,0 +1 @@
1
+ requests>=2.32.0
@@ -0,0 +1 @@
1
+ {"data":{"resource":{"created_at":"2026-10-15T10:00:00Z","failure_message":"Awaiting provider confirmation","id":"cancel/a b","payment_id":"payment/a b","reason":"Customer left checkout","reason_code":"provider_outcome_unknown","requested_by_app_id":"app-1","source":"app","status":"outcome_unknown","updated_at":"2026-10-15T10:00:00Z"}},"id":"evt_cancel_checkout_fixture","meta":{"app_id":"app-1","version":"1.0"},"security":{"algorithm":"HMAC-SHA256","signature":"sha256=e4ed7dbfed64444bf2b95913ca9bcd4943f199db6cc4e83d8929f3223dbdf84a"},"source":"payment","timestamp":"2026-10-15T10:00:00Z","type":"checkout.cancellation.updated.v1"}
@@ -0,0 +1 @@
1
+ Snapshot of tinker-payments-openapi/fixtures/merchant-lifecycle.json, contract_version 1. Update the canonical fixture first; tests compare this snapshot when the sibling repository is present. Set TINKER_LIFECYCLE_FIXTURE to test a separate canonical checkout.