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.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -1
- data/README.md +232 -107
- data/lib/tinker/api/base_manager.rb +73 -10
- data/lib/tinker/api/payout_manager.rb +31 -0
- data/lib/tinker/api/subscription_manager.rb +122 -0
- data/lib/tinker/api/transaction_manager.rb +33 -0
- data/lib/tinker/auth/authentication_manager.rb +32 -7
- data/lib/tinker/config/configuration.rb +21 -3
- data/lib/tinker/config/endpoints.rb +10 -5
- data/lib/tinker/enum/gateway.rb +1 -0
- data/lib/tinker/enum/payment_status.rb +11 -0
- data/lib/tinker/exception/api_exception.rb +18 -2
- data/lib/tinker/exception/feature_not_supported_exception.rb +28 -0
- data/lib/tinker/http/http_client.rb +24 -4
- data/lib/tinker/model/api_meta.rb +23 -0
- data/lib/tinker/model/cancellation_result.rb +14 -0
- data/lib/tinker/model/checkout_cancellation.rb +8 -0
- data/lib/tinker/model/dto/callback_data_dto.rb +4 -8
- data/lib/tinker/model/dto/cancellation_request.rb +17 -0
- data/lib/tinker/model/dto/create_subscription_plan_request_dto.rb +32 -0
- data/lib/tinker/model/dto/create_subscription_request_dto.rb +30 -0
- data/lib/tinker/model/dto/initiate_payment_request_dto.rb +10 -2
- data/lib/tinker/model/dto/initiation_data_dto.rb +10 -11
- data/lib/tinker/model/dto/managed_checkout.rb +25 -0
- data/lib/tinker/model/dto/payout_request.rb +18 -0
- data/lib/tinker/model/dto/query_data_dto.rb +4 -8
- data/lib/tinker/model/dto/query_payment_request_dto.rb +1 -1
- data/lib/tinker/model/dto/refund_request.rb +17 -0
- data/lib/tinker/model/dto/subscription_customer_dto.rb +28 -0
- data/lib/tinker/model/lifecycle_resource.rb +28 -0
- data/lib/tinker/model/payout.rb +8 -0
- data/lib/tinker/model/payout_result.rb +14 -0
- data/lib/tinker/model/refund.rb +8 -0
- data/lib/tinker/model/refund_result.rb +15 -0
- data/lib/tinker/model/transaction.rb +2 -7
- data/lib/tinker/payments.rb +21 -4
- data/lib/tinker/version.rb +1 -1
- data/lib/tinker/webhook/dto/invoice_event_data_dto.rb +5 -4
- data/lib/tinker/webhook/dto/onboarding_event_data_dto.rb +19 -0
- data/lib/tinker/webhook/dto/payment_event_data_dto.rb +4 -8
- data/lib/tinker/webhook/dto/settlement_event_data_dto.rb +8 -5
- data/lib/tinker/webhook/dto/subscription_event_data_dto.rb +12 -6
- data/lib/tinker/webhook/webhook_event.rb +44 -3
- data/lib/tinker/webhook/webhook_handler.rb +42 -0
- data/lib/tinker.rb +19 -0
- metadata +27 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b22b3a0864b689dc0e16a306b043c089d9c9e9e6e26bf48f6dacf21980d2d73d
|
|
4
|
+
data.tar.gz: 146a36adc5a4685cffe65a69b96f252a7ef48b35307fe396b5fb5c17c55f0ee0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 474f073c4aeab71b1a9cb28eb61648a3fcfa1a440afb64ce703ddad8d09b8f6a1ec6734abfcfdfc27752d77c6c0e07fe3c6db82afeb4666f0751446662f04cd8
|
|
7
|
+
data.tar.gz: 1da0ef57ad9119aa1b3377db69f679ba1430a05901632a0c4c5dfb79ca867e47bfc3c218f0867afdef927f691c530c4de5382378d616279c662cab2e5d10c6ca
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -1,166 +1,291 @@
|
|
|
1
1
|
# Tinker Payments Ruby SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ruby 3.0+ SDK for payments, subscriptions and signed webhooks. Keep secret keys on the server.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install and configure
|
|
6
6
|
|
|
7
|
-
Add
|
|
7
|
+
Add `gem 'tinker-pay-ruby-sdk'` to your Gemfile and run `bundle install`, or run:
|
|
8
8
|
|
|
9
|
-
```
|
|
10
|
-
gem
|
|
9
|
+
```sh
|
|
10
|
+
gem install tinker-pay-ruby-sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
```ruby
|
|
14
|
+
require 'tinker'
|
|
14
15
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
### Apple Pay / Google Pay
|
|
31
41
|
|
|
32
42
|
```ruby
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
amount: 100
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
## Managed recurring billing
|
|
77
131
|
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
billing.expire_checkout('checkout-id', 'expire-123')
|
|
173
|
+
```
|
|
98
174
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
194
|
+
### Changing the trial end
|
|
124
195
|
|
|
125
196
|
```ruby
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
267
|
+
## Errors and response metadata
|
|
156
268
|
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
283
|
+
## Development
|
|
164
284
|
|
|
165
|
-
|
|
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(
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
response = @http_client.post(url, headers: headers, body: body)
|
|
31
|
-
result = response.json
|
|
32
|
+
headers.merge!(request_headers)
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|