tinker-pay-ruby-sdk 0.1.0 → 0.6.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +0 -1
  3. data/README.md +232 -107
  4. data/lib/tinker/api/base_manager.rb +73 -10
  5. data/lib/tinker/api/payout_manager.rb +31 -0
  6. data/lib/tinker/api/subscription_manager.rb +122 -0
  7. data/lib/tinker/api/transaction_manager.rb +33 -0
  8. data/lib/tinker/auth/authentication_manager.rb +32 -7
  9. data/lib/tinker/config/configuration.rb +21 -3
  10. data/lib/tinker/config/endpoints.rb +10 -5
  11. data/lib/tinker/enum/gateway.rb +1 -0
  12. data/lib/tinker/enum/payment_status.rb +11 -0
  13. data/lib/tinker/exception/api_exception.rb +18 -2
  14. data/lib/tinker/exception/feature_not_supported_exception.rb +28 -0
  15. data/lib/tinker/http/http_client.rb +24 -4
  16. data/lib/tinker/model/api_meta.rb +23 -0
  17. data/lib/tinker/model/cancellation_result.rb +14 -0
  18. data/lib/tinker/model/checkout_cancellation.rb +8 -0
  19. data/lib/tinker/model/dto/callback_data_dto.rb +4 -8
  20. data/lib/tinker/model/dto/cancellation_request.rb +17 -0
  21. data/lib/tinker/model/dto/create_subscription_plan_request_dto.rb +32 -0
  22. data/lib/tinker/model/dto/create_subscription_request_dto.rb +30 -0
  23. data/lib/tinker/model/dto/initiate_payment_request_dto.rb +10 -2
  24. data/lib/tinker/model/dto/initiation_data_dto.rb +10 -11
  25. data/lib/tinker/model/dto/managed_checkout.rb +25 -0
  26. data/lib/tinker/model/dto/payout_request.rb +18 -0
  27. data/lib/tinker/model/dto/query_data_dto.rb +4 -8
  28. data/lib/tinker/model/dto/query_payment_request_dto.rb +1 -1
  29. data/lib/tinker/model/dto/refund_request.rb +17 -0
  30. data/lib/tinker/model/dto/subscription_customer_dto.rb +28 -0
  31. data/lib/tinker/model/lifecycle_resource.rb +28 -0
  32. data/lib/tinker/model/payout.rb +8 -0
  33. data/lib/tinker/model/payout_result.rb +14 -0
  34. data/lib/tinker/model/refund.rb +8 -0
  35. data/lib/tinker/model/refund_result.rb +15 -0
  36. data/lib/tinker/model/transaction.rb +2 -7
  37. data/lib/tinker/payments.rb +21 -4
  38. data/lib/tinker/version.rb +1 -1
  39. data/lib/tinker/webhook/dto/invoice_event_data_dto.rb +5 -4
  40. data/lib/tinker/webhook/dto/onboarding_event_data_dto.rb +19 -0
  41. data/lib/tinker/webhook/dto/payment_event_data_dto.rb +4 -8
  42. data/lib/tinker/webhook/dto/settlement_event_data_dto.rb +8 -5
  43. data/lib/tinker/webhook/dto/subscription_event_data_dto.rb +12 -6
  44. data/lib/tinker/webhook/webhook_event.rb +44 -3
  45. data/lib/tinker/webhook/webhook_handler.rb +42 -0
  46. data/lib/tinker.rb +19 -0
  47. metadata +27 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0b952007a71c15acfc412bf15ea914030bad28ff7d85fb947c64e81eb4e354d
4
- data.tar.gz: 0c47365417e7520100310c3c87d9e0e72b28b7add64060a8a4c7bac300e93bff
3
+ metadata.gz: b22b3a0864b689dc0e16a306b043c089d9c9e9e6e26bf48f6dacf21980d2d73d
4
+ data.tar.gz: 146a36adc5a4685cffe65a69b96f252a7ef48b35307fe396b5fb5c17c55f0ee0
5
5
  SHA512:
6
- metadata.gz: 827689281ecc69e936adf6edf3ec4e63d7e5425310dc7e47405c2062c4ab2f807abf946a0de279c78f7f1d5308b72db29c56f8b9bb35adf115374bd3179e45d0
7
- data.tar.gz: 6c9bab9ab2168427ac3c183dfce37c3560c9fc646e8e9439c0dbe700a4cbd2c0f83ca5ac785d7074b63f606d3a539ed4488e1ba8112c9bcf638f1324d7622949
6
+ metadata.gz: 474f073c4aeab71b1a9cb28eb61648a3fcfa1a440afb64ce703ddad8d09b8f6a1ec6734abfcfdfc27752d77c6c0e07fe3c6db82afeb4666f0751446662f04cd8
7
+ data.tar.gz: 1da0ef57ad9119aa1b3377db69f679ba1430a05901632a0c4c5dfb79ca867e47bfc3c218f0867afdef927f691c530c4de5382378d616279c662cab2e5d10c6ca
data/.rubocop.yml CHANGED
@@ -6,7 +6,6 @@ AllCops:
6
6
  - 'spec/spec_helper.rb'
7
7
  - 'bin/**/*'
8
8
  - 'tmp/**/*'
9
- - '*.gemspec'
10
9
 
11
10
  Style/Documentation:
12
11
  Enabled: false
data/README.md CHANGED
@@ -1,166 +1,291 @@
1
1
  # Tinker Payments Ruby SDK
2
2
 
3
- Official Ruby SDK for [Tinker Payments API](https://payments.tinker.co.ke/docs).
3
+ Ruby 3.0+ SDK for payments, subscriptions and signed webhooks. Keep secret keys on the server.
4
4
 
5
- ## Installation
5
+ ## Install and configure
6
6
 
7
- Add this line to your application's Gemfile:
7
+ Add `gem 'tinker-pay-ruby-sdk'` to your Gemfile and run `bundle install`, or run:
8
8
 
9
- ```ruby
10
- gem 'tinker-pay-ruby-sdk'
9
+ ```sh
10
+ gem install tinker-pay-ruby-sdk
11
11
  ```
12
12
 
13
- And then execute:
13
+ ```ruby
14
+ require 'tinker'
14
15
 
15
- ```bash
16
- bundle install
16
+ tinker = Tinker::Payments.new(api_public_key: 'pk_test_xxx', api_secret_key: 'sk_test_xxx')
17
17
  ```
18
18
 
19
- Or install it yourself as:
19
+ Test key prefixes select `https://sandbox-api.tinkerpayments.com/v1/`; other keys select `https://api.tinkerpayments.com/v1/`. Override with `base_url:`. A custom `http_client:` must implement `get(url, headers:)` and `post(url, headers:, body:)`, returning responses with `status_code`, `json` and `headers`.
20
20
 
21
- ```bash
22
- gem install tinker-pay-ruby-sdk
21
+ ## Payments
22
+
23
+ ```ruby
24
+ request = Tinker::Model::Dto::InitiatePaymentRequestDto.new(
25
+ amount: 100, currency: 'KES', gateway: Tinker::Enum::Gateway::MPESA,
26
+ merchant_reference: 'ORDER-12345',
27
+ return_url: 'https://merchant.example/payment/return',
28
+ customer_phone: '+254712345678'
29
+ )
30
+ payment = tinker.transactions.initiate(request)
31
+ result = tinker.transactions.query(Tinker::Model::Dto::QueryPaymentRequestDto.new(
32
+ payment_reference: 'TXN-abc123xyz', gateway: Tinker::Enum::Gateway::MPESA
33
+ ))
23
34
  ```
24
35
 
25
- ## Requirements
36
+ Any registered provider id works as `gateway:`, so new providers need no SDK release; `Tinker::Enum::Gateway` only lists common ones (`PESAPAL` included). List providers and their features with `tinker.transactions.capabilities(currency: 'KES', country: 'KE')` (both optional). Payment statuses are strings: `completed` becomes `success`, and unknown future statuses are kept as sent (`reversed` is `PaymentStatus::REVERSED`).
26
37
 
27
- - Ruby 3.0 or higher
28
- - No external runtime dependencies (uses standard library)
38
+ Payments use currency units. Mpesa requires `customer_phone`; `return_url` is the browser destination, while webhooks are configured separately in the dashboard. Hosted providers may return `payment.initiation_data&.authorization_url` for a redirect.
29
39
 
30
- ## Quick Start
40
+ ### Apple Pay / Google Pay
31
41
 
32
42
  ```ruby
33
- require 'tinker'
34
-
35
- tinker = Tinker::Payments.new(
36
- api_public_key: 'your-public-key',
37
- api_secret_key: 'your-secret-key'
43
+ request = Tinker::Model::Dto::InitiatePaymentRequestDto.new(
44
+ amount: 100, currency: 'USD', gateway: Tinker::Enum::Gateway::STRIPE,
45
+ merchant_reference: 'ORDER-12345',
46
+ return_url: 'https://merchant.example/payment/return',
47
+ payment_method: 'apple_pay' # or 'google_pay'; 'bnpl' is Klarna on Stripe Checkout
38
48
  )
49
+ data = tinker.transactions.initiate(request).initiation_data
50
+ # return data.client_secret and data.publishable_key to the browser as JSON
39
51
  ```
40
52
 
41
- ## Usage
53
+ ```html
54
+ <div id="wallet"></div>
55
+ <p id="wallet-error" role="alert"></p>
56
+ <script src="https://js.stripe.com/v3/"></script>
57
+ <script type="module">
58
+ const { clientSecret, publishableKey } = await fetch("/checkout/wallet", { method: "POST" }).then((r) => r.json());
59
+ const stripe = Stripe(publishableKey);
60
+ const elements = stripe.elements({ clientSecret });
61
+ const wallet = elements.create("expressCheckout", { paymentMethods: { applePay: "always", googlePay: "always" } });
62
+ wallet.mount("#wallet");
63
+ wallet.on("confirm", async (event) => {
64
+ const { error } = await stripe.confirmPayment({ elements, clientSecret, redirect: "if_required", confirmParams: { return_url: location.href } });
65
+ if (error) {
66
+ event.paymentFailed({ reason: "fail" });
67
+ document.querySelector("#wallet-error").textContent = error.message;
68
+ }
69
+ });
70
+ </script>
71
+ ```
42
72
 
43
- ### Initiate a Payment
73
+ The result arrives through `payment.*` webhooks. Apple Pay and Google Pay on the web need the checkout domain registered with Stripe.
44
74
 
45
- ```ruby
46
- require 'tinker'
75
+ Plan-based subscriptions use `tinker.subscriptions.create_plan`, `list_plans`, `create`, `list` and `cancel` with the request DTOs in `Tinker::Model::Dto`.
76
+
77
+ ## Provider examples
47
78
 
79
+ Every provider takes the same request. Keys and values are fake.
80
+
81
+ ```ruby
82
+ dto = Tinker::Model::Dto
83
+ tx = tinker.transactions
84
+ return_url = 'https://shop.example/return'
85
+
86
+ # Redirect (Paystack, Pesapal, PayPal, Flutterwave, DPO, Interswitch, Adyen, Plaid, hosted Stripe):
87
+ # send the customer to payment.initiation_data&.authorization_url.
88
+ payment = tx.initiate(dto::InitiatePaymentRequestDto.new(
89
+ amount: 2500, currency: 'NGN', gateway: 'paystack', merchant_reference: 'ORDER-1',
90
+ return_url: return_url, customer_email: 'jane@example.com'
91
+ ))
92
+
93
+ # Phone approval (M-Pesa, MTN MoMo): no authorization_url. The customer approves on their phone
94
+ # and the result arrives as a payment.completed or payment.failed webhook.
95
+ tx.initiate(dto::InitiatePaymentRequestDto.new(
96
+ amount: 100, currency: 'KES', gateway: 'mpesa', merchant_reference: 'ORDER-2',
97
+ return_url: return_url, customer_phone: '+254712345678'
98
+ ))
99
+
100
+ # Klarna (payment_method 'bnpl' on Stripe or Adyen) is captured at once. Adyen also needs country
101
+ # (AT DK FI DE NO SE CH NL GB US) and that country's currency.
102
+ tx.initiate(dto::InitiatePaymentRequestDto.new(
103
+ amount: 49.9, currency: 'EUR', gateway: 'adyen', merchant_reference: 'ORDER-3',
104
+ return_url: return_url, customer_email: 'jane@example.com', country: 'DE', payment_method: 'bnpl'
105
+ ))
106
+
107
+ # Plaid (plaid_transfer, plaid_payments): customer_name is required.
108
+ # Payment Initiation INITIATED counts as paid.
109
+ tx.initiate(dto::InitiatePaymentRequestDto.new(
110
+ amount: 40, currency: 'GBP', gateway: 'plaid_payments', merchant_reference: 'ORDER-4',
111
+ return_url: return_url, customer_email: 'jane@example.com', customer_name: 'Jane Doe', country: 'GB'
112
+ ))
113
+
114
+ # Ask first, then handle a refusal.
115
+ tx.capabilities(currency: 'KES', country: 'KE')['providers'].each do |p|
116
+ puts "#{p['gateway']} #{p['capabilities']['pay_mobile_money']}"
117
+ end
48
118
  begin
49
- initiate_request = Tinker::Model::Dto::InitiatePaymentRequestDto.new(
50
- amount: 100.00,
51
- currency: 'KES',
52
- gateway: Tinker::Enum::Gateway::MPESA,
53
- merchant_reference: 'ORDER-12345',
54
- return_url: 'https://your-app.com/payment/return',
55
- customer_phone: '+254712345678',
56
- transaction_desc: 'Payment for order #12345',
57
- metadata: { order_id: '12345' }
58
- )
59
-
60
- transaction = tinker.transactions.initiate(initiate_request)
61
- initiation_data = transaction.initiation_data
62
-
63
- if initiation_data&.authorization_url
64
- # Redirect user to authorization URL (Paystack, Stripe, etc.)
65
- redirect_to initiation_data.authorization_url
66
- end
67
- rescue Tinker::Exception::ApiException => e
68
- puts "API Error: #{e.message}"
69
- rescue Tinker::Exception::NetworkException => e
70
- puts "Network Error: #{e.message}"
119
+ tx.initiate(dto::InitiatePaymentRequestDto.new(
120
+ amount: 100, currency: 'KES', gateway: 'mpesa', merchant_reference: 'ORDER-5',
121
+ return_url: return_url, customer_phone: '+254712345678', payment_method: 'apple_pay'
122
+ ))
123
+ rescue Tinker::Exception::FeatureNotSupportedException => e
124
+ puts "#{e.gateway} #{e.feature} #{e.alternatives.join(',')}" # retry on one of the alternatives
71
125
  end
72
126
  ```
73
127
 
74
- **Note:** The `return_url` is where users are redirected after payment completion. Webhooks are configured separately in your dashboard.
128
+ Pay-as-you-go apps use Tinker's provider credentials; your own need a paid plan (HTTP 403 `PAID_PLAN_REQUIRED`).
75
129
 
76
- ### Query a Transaction
130
+ ## Managed recurring billing
77
131
 
78
- ```ruby
79
- query_request = Tinker::Model::Dto::QueryPaymentRequestDto.new(
80
- payment_reference: 'TXN-abc123xyz',
81
- gateway: Tinker::Enum::Gateway::MPESA
82
- )
132
+ 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.
83
133
 
84
- transaction = tinker.transactions.query(query_request)
134
+ | Method | Purpose |
135
+ |---|---|
136
+ | `checkout` | Create an exact-price recurring checkout |
137
+ | `expire_checkout` | Expire an abandoned, unpaid checkout |
138
+ | `billing_resource` | Read a managed billing resource |
139
+ | `change_quantity` | Change the current quantity |
140
+ | `schedule_quantity` | Schedule a quantity change |
141
+ | `clear_scheduled_quantity` | Remove a scheduled quantity change |
142
+ | `set_cancel_at_renewal` | Set cancellation at renewal; pass false to clear it |
143
+ | `set_trial_end` | Move the next charge to a given time |
144
+ | `end_trial` | End a trial now and charge the full period |
145
+ | `billing_operation` | Recover the durable result by idempotency key |
146
+ | `payment_method_portal` | Create a short-lived hosted payment-method link |
147
+ | `billing_payment_history` | Read one page of captured payments and adjustments |
85
148
 
86
- if transaction.successful?
87
- query_data = transaction.query_data
88
- puts "Amount: #{query_data.amount} #{query_data.currency}"
89
- end
149
+ ```ruby
150
+ billing = tinker.subscriptions
151
+ billing.checkout(Tinker::Model::Dto::ManagedCheckout.new(
152
+ external_customer_id: 'workspace-123', email: 'buyer@example.com', name: 'Buyer',
153
+ currency: 'EUR', unit_amount: 15000, quantity: 3, interval: 'year',
154
+ return_url: 'https://example.com/billing', idempotency_key: 'checkout-123'
155
+ ))
156
+ billing.change_quantity('resource-id', 4, 'quantity-123')
157
+ billing.set_cancel_at_renewal('resource-id', false, 'resume-123')
158
+ billing.payment_method_portal('resource-id', 'https://example.com/billing', 'portal-123')
159
+ page = billing.billing_payment_history('resource-id', '', 0)
160
+ # After an uncertain command response, recover using the SAME key:
161
+ operation = billing.billing_operation('quantity-123')
90
162
  ```
91
163
 
92
- ### Handle Webhooks
164
+ Pass Integer values for minor-unit amounts, quantities and snapshots; do not convert
165
+ through floating-point prices. `ManagedCheckout` exposes `to_hash`.
166
+
167
+ ### Expiring an abandoned checkout
93
168
 
94
- Webhooks support multiple event types: payment, subscription, invoice, and settlement. Check the event type and handle accordingly:
169
+ An abandoned checkout link can still be completed later and create a second subscription. Expire it to close the link.
95
170
 
96
171
  ```ruby
97
- require 'tinker'
172
+ billing.expire_checkout('checkout-id', 'expire-123')
173
+ ```
98
174
 
99
- event = tinker.webhooks.handle_from_request(request.body.read)
100
-
101
- # Check event type
102
- if event.payment_event?
103
- payment_data = event.payment_data
104
- # Handle payment.completed, payment.failed, etc.
105
- elsif event.subscription_event?
106
- subscription_data = event.subscription_data
107
- # Handle subscription.created, subscription.cancelled, etc.
108
- elsif event.invoice_event?
109
- invoice_data = event.invoice_data
110
- # Handle invoice.paid, invoice.failed
111
- elsif event.settlement_event?
112
- settlement_data = event.settlement_data
113
- # Handle settlement.processed
114
- end
175
+ - `open` checkout: becomes `expired` (`managed_state.status` is `canceled`) and the session is returned.
176
+ - Already `expired`: the session is returned unchanged, so retrying is safe.
177
+ - `complete`, including completion while the call is in flight: HTTP 409 `CHECKOUT_COMPLETED` with `outcome: not_applied`. Read the checkout with `billing_resource` and use its subscription.
115
178
 
116
- # Access event details
117
- puts "Event type: #{event.type}" # e.g., "payment.completed"
118
- puts "Event source: #{event.source}" # e.g., "payment"
119
- puts "App ID: #{event.meta.app_id}"
120
- puts "Signature: #{event.security.signature}"
179
+ A non-checkout ID (`sub_...`) returns 400 `INVALID_ID`. An expired checkout sends no `subscription.cancelled` webhook.
180
+
181
+ ### Trials with a required payment method
182
+
183
+ Set `trial_days` (1 to 730) to collect a card at checkout and charge first when the trial ends. The SDK sends `trial_days` only when it is above 0, so checkouts without a trial keep working against servers released before trial support. A gateway without trial support returns HTTP 422 `FEATURE_NOT_SUPPORTED` with `outcome: not_applied`. Limiting a customer to one trial is the app's responsibility. Alternatively set `trial_end` to an exact end 48 hours to 730 days ahead; it cannot be combined with `trial_days` (HTTP 400), and the SDK sends it only when set.
184
+
185
+ ```ruby
186
+ billing.checkout(Tinker::Model::Dto::ManagedCheckout.new(
187
+ external_customer_id: 'workspace-123', email: 'buyer@example.com', name: 'Buyer',
188
+ currency: 'EUR', unit_amount: 15000, quantity: 3, interval: 'year',
189
+ return_url: 'https://example.com/billing', idempotency_key: 'checkout-124',
190
+ trial_days: 14
191
+ ))
121
192
  ```
122
193
 
123
- For payment events only, you can convert to a `Transaction` object:
194
+ ### Changing the trial end
124
195
 
125
196
  ```ruby
126
- transaction = tinker.webhooks.handle_as_transaction(request.body.read)
127
- if transaction && transaction.successful?
128
- callback_data = transaction.callback_data
129
- puts "Payment successful: #{callback_data.reference}"
130
- end
197
+ billing.set_trial_end('resource-id', Time.now + (30 * 86_400), 'trial-end-123')
198
+ billing.end_trial('resource-id', 'end-trial-123')
131
199
  ```
132
200
 
133
- ## Custom HTTP Client
201
+ `set_trial_end` moves the next charge to a given time 1 hour to 730 days ahead. On a trialing subscription it moves the trial end earlier or later; on a paid (`active`) subscription only to a time on or after `current_period_end`, which makes it `trialing` without charging. Scheduled seat changes must be cleared first. `end_trial` ends a trial now and charges the full period immediately; if the charge fails the subscription becomes `past_due`. Both return the subscription with `managed_state`; state-dependent rejections arrive as 502 `BILLING_GATEWAY_ERROR` with `outcome: not_applied`. Ending a trial early sends `invoice.paid` and `payment.updated` (not `subscription.renewed`); extending a paid subscription produces a €0 `invoice.paid`.
202
+
203
+ Checkout and billing resource responses include a provider-neutral `managed_state` map: `status` (`pending`, `trialing`, `active`, `past_due`, `paused` or `canceled`), `trial_ends_at`, `current_period_end` and `cancel_at_period_end`. Read it instead of raw provider fields; grant access while `status` is `trialing` or `active` and `current_period_end` is in the future. Managed subscription webhooks carry `billing_status` and `trial_ends_at`, and `subscription.trial_will_end` arrives up to three days before the trial ends. The trial's €0 invoice sends `invoice.paid` with amount 0 and is not a payment.
204
+
205
+ 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.
134
206
 
135
- You can use your own HTTP client by passing it to the constructor:
207
+ 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.
208
+
209
+ 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).
210
+
211
+ 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.
212
+
213
+ ## Refunds, merchant payouts and checkout cancellation
214
+
215
+ Payouts withdraw the merchant's available Tinker wallet balance to an existing
216
+ verified payout account. The app owner enables SDK withdrawal access in the
217
+ dashboard; the SDK cannot approve requests or verify payout accounts. Fees come
218
+ out of the requested amount: requesting 100 with a fee of 2 reserves 100 and pays
219
+ 98. Quote first and send the quoted fee.
136
220
 
137
221
  ```ruby
138
- require 'tinker'
222
+ refund = tinker.transactions.refund(payment_id,
223
+ Tinker::Model::Dto::RefundRequest.new(reason: 'requested_by_customer', amount: 25), 'refund-order-42')
224
+ destination = tinker.payouts.methods.find { |method| method['is_verified'] && method['status'] == 'active' }
225
+ raise 'Configure an active, verified payout method' unless destination
226
+ method_id = destination['id']
227
+ quote = tinker.payouts.quote(payout_method_id: method_id, amount: 100, currency: destination['currency'])
228
+ payout = tinker.payouts.request_payout(
229
+ Tinker::Model::Dto::PayoutRequest.new(payout_method_id: method_id, amount: 100,
230
+ currency: destination['currency'], expected_fee_amount: quote['fee_amount']), 'withdrawal-42')
231
+ status = tinker.payouts.get(payout.withdrawal.id)
232
+ cancellation = tinker.transactions.cancel_checkout(unpaid_payment_id,
233
+ Tinker::Model::Dto::CancellationRequest.new(reason: 'Order cancelled'), 'cancel-order-42')
234
+ ```
139
235
 
140
- # Create a custom HTTP client that responds to #post(url, headers:, body:)
141
- # and returns an object with #status_code, #body, and #json methods
142
- custom_client = MyCustomHttpClient.new
236
+ Reuse the same key for retries of one operation. Keys are required, limited to
237
+ 255 UTF-8 bytes, and never silently regenerated. Different payloads with one key
238
+ produce a conflict. `pending_review` refunds and `pending_approval` payouts are
239
+ accepted requests, not completed transfers. Only `refunded`, `paid`, or
240
+ provider-confirmed `cancelled` indicate completion. Query `outcome_unknown`
241
+ cancellations instead of assuming failure and retrying.
143
242
 
144
- tinker = Tinker::Payments.new(
145
- api_public_key: 'your-public-key',
146
- api_secret_key: 'your-secret-key',
147
- http_client: custom_client
148
- )
243
+ Use `refunds`, `get_refund`, `cancellations`, `get_cancellation`, and
244
+ `payouts.list` for history and status. `ApiException#details` preserves the full
245
+ error envelope; `details.dig('error', 'details')` includes the recorded request ID
246
+ for unsupported cancellation (422) or updated quote for a fee conflict (409).
247
+ Subscription cancellation is a separate operation. Existing administrative queues
248
+ handle manual refunds and withdrawals.
249
+
250
+ Cancellation history returns the newest 100 requests and `has_more`; use the
251
+ single-request lookup to track a known older request. A cancellation with
252
+ `payment_conflict` means later successful collection conflicts with cancellation
253
+ evidence; investigate the successful payment and use a refund if needed.
254
+
255
+ ## Webhooks
256
+
257
+ ```ruby
258
+ event = tinker.webhooks.handle(raw_body)
259
+ raise 'Invalid webhook signature' unless tinker.webhooks.verify_signature(event, webhook_secret)
260
+ transaction = event.to_transaction # nil for setup/lifecycle events
149
261
  ```
150
262
 
151
- ## Documentation
263
+ 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.
152
264
 
153
- For detailed API documentation, visit [Tinker Payments API Documentation](https://payments.tinker.co.ke/docs).
265
+ 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.
154
266
 
155
- ## Development
267
+ ## Errors and response metadata
156
268
 
157
- After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
269
+ 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. Use `manager.last_meta` or `tinker.last_auth_meta` for response metadata.
158
270
 
159
- ## Contributing
271
+ An unsupported feature raises `Tinker::Exception::FeatureNotSupportedException` (an `ApiException`) with `gateway`, `feature` and `alternatives`; it replaces the former `CHECKOUT_CANCELLATION_UNSUPPORTED` code.
160
272
 
161
- Bug reports and pull requests are welcome on GitHub at https://github.com/tinker/payments-ruby-sdk.
273
+ ```ruby
274
+ begin
275
+ tinker.transactions.query(Tinker::Model::Dto::QueryPaymentRequestDto.new(
276
+ payment_reference: 'TXN-abc123xyz', gateway: Tinker::Enum::Gateway::MPESA
277
+ ))
278
+ rescue Tinker::Exception::ApiException => error
279
+ warn "#{error.error_code}: #{error.message}"
280
+ end
281
+ ```
162
282
 
163
- ## License
283
+ ## Development
164
284
 
165
- MIT License
285
+ ```sh
286
+ bundle install
287
+ bundle exec rubocop
288
+ bundle exec rake spec
289
+ ```
166
290
 
291
+ MIT License.
@@ -1,19 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module Tinker
6
7
  module Api
7
8
  class BaseManager
9
+ attr_reader :last_meta
10
+
8
11
  def initialize(config:, http_client:, auth_manager:)
9
12
  @config = config
10
13
  @http_client = http_client
11
14
  @auth_manager = auth_manager
15
+ @last_meta = nil
12
16
  end
13
17
 
14
18
  protected
15
19
 
16
- def request(_method, endpoint, data = {})
20
+ def request(method, endpoint, data = {}, request_headers = {})
17
21
  base_url = @config.base_url.chomp('/')
18
22
  endpoint = endpoint[1..] if endpoint.start_with?('/')
19
23
  url = "#{base_url}/#{endpoint}"
@@ -25,23 +29,82 @@ module Tinker
25
29
  'Content-Type' => 'application/json'
26
30
  }
27
31
 
28
- body = data.empty? ? nil : JSON.generate(data)
29
-
30
- response = @http_client.post(url, headers: headers, body: body)
31
- result = response.json
32
+ headers.merge!(request_headers)
32
33
 
33
- if response.status_code >= 400
34
- message = result.is_a?(Hash) ? (result['message'] || result['error'] || 'Unknown error') : 'Unknown error'
35
- raise Exception::ApiException.new(message, Exception::ExceptionCode::API_ERROR)
36
- end
34
+ body = data.nil? || data.empty? ? nil : JSON.generate(data)
35
+ response = send_request(method, url, headers, body)
36
+ result = decode_response(response)
37
+ raise api_error(response, result) if response.status_code >= 400
37
38
 
38
- result || {}
39
+ unwrap_response(response, result)
39
40
  rescue Exception::ApiException => e
40
41
  raise e
41
42
  rescue StandardError => e
42
43
  raise Exception::NetworkException.new("Failed to communicate with Tinker API: #{e.message}",
43
44
  Exception::ExceptionCode::NETWORK_ERROR, e)
44
45
  end
46
+
47
+ def operation_headers(key)
48
+ key = key.strip
49
+ unless key.bytesize.between?(1, 255) && !key.match?(/[\r\n]/)
50
+ raise Exception::InvalidPayloadException, 'Idempotency key must contain 1 to 255 bytes without line breaks'
51
+ end
52
+
53
+ { 'Idempotency-Key' => key }
54
+ end
55
+
56
+ def resource_id(id)
57
+ raise Exception::InvalidPayloadException, 'Resource ID is required' if id.strip.empty?
58
+
59
+ URI.encode_www_form_component(id).gsub('+', '%20')
60
+ end
61
+
62
+ def payment_path(id)
63
+ "merchant/payment/#{resource_id(id)}"
64
+ end
65
+
66
+ def send_request(method, url, headers, body)
67
+ verb = method.to_s.downcase
68
+ return @http_client.get(url, headers: headers) if verb == 'get'
69
+
70
+ @http_client.public_send(verb, url, headers: headers, body: body)
71
+ end
72
+
73
+ def decode_response(response)
74
+ response.json
75
+ rescue Exception::InvalidPayloadException
76
+ raise if response.status_code < 400
77
+
78
+ {}
79
+ end
80
+
81
+ def unwrap_response(response, result)
82
+ return result || {} unless result.is_a?(Hash) && result.key?('success')
83
+
84
+ @last_meta = Model::ApiMeta.new(result['meta'] || {})
85
+ raise api_error(response, result) if result['success'] == false
86
+
87
+ data = result['data']
88
+ data.is_a?(Hash) ? data : { 'value' => data }
89
+ end
90
+
91
+ def api_error(response, result)
92
+ error = result.is_a?(Hash) && result['error'].is_a?(Hash) ? result['error'] : {}
93
+ feature = Exception::FeatureNotSupportedException
94
+ klass = error['code'] == feature::CODE ? feature : Exception::ApiException
95
+ klass.new(extract_error_message(result), http_status: response.status_code,
96
+ details: result, headers: response.headers || {})
97
+ end
98
+
99
+ def extract_error_message(result)
100
+ if result.is_a?(Hash)
101
+ error = result['error']
102
+ return error['message'] || error['code'] || 'Unknown error' if error.is_a?(Hash)
103
+
104
+ return result['message'] || 'Unknown error'
105
+ end
106
+ 'Unknown error'
107
+ end
45
108
  end
46
109
  end
47
110
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tinker
4
+ module Api
5
+ # Payouts are withdrawals to the merchant's own verified account.
6
+ class PayoutManager < BaseManager
7
+ def methods
8
+ request('GET', 'merchant/payout-methods').fetch('value', [])
9
+ end
10
+
11
+ def quote(payout_method_id:, amount:, currency: nil)
12
+ params = { payout_method_id: payout_method_id, amount: amount, currency: currency }.compact
13
+ request('GET', "merchant/payouts/quote?#{URI.encode_www_form(params)}")
14
+ end
15
+
16
+ def request_payout(payload, key)
17
+ Model::PayoutResult.new(request('POST', 'merchant/payouts', payload.to_hash, operation_headers(key)))
18
+ end
19
+
20
+ def list(page: nil, per_page: nil, status: nil, currency: nil)
21
+ params = { page: page, per_page: per_page, status: status, currency: currency }.compact
22
+ query = params.empty? ? '' : "?#{URI.encode_www_form(params)}"
23
+ request('GET', "merchant/payouts#{query}")
24
+ end
25
+
26
+ def get(id)
27
+ Model::Payout.new(request('GET', "merchant/payouts/#{resource_id(id)}"))
28
+ end
29
+ end
30
+ end
31
+ end