malipopay 1.0.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 +7 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +312 -0
- data/Rakefile +7 -0
- data/docs/configuration.md +300 -0
- data/docs/customers.md +133 -0
- data/docs/error-handling.md +274 -0
- data/docs/getting-started.md +160 -0
- data/docs/invoices.md +200 -0
- data/docs/payments.md +284 -0
- data/docs/sms.md +182 -0
- data/docs/webhooks.md +219 -0
- data/lib/malipopay/client.rb +76 -0
- data/lib/malipopay/errors.rb +49 -0
- data/lib/malipopay/http_client.rb +151 -0
- data/lib/malipopay/resources/account.rb +60 -0
- data/lib/malipopay/resources/customers.rb +60 -0
- data/lib/malipopay/resources/invoices.rb +61 -0
- data/lib/malipopay/resources/payments.rb +88 -0
- data/lib/malipopay/resources/products.rb +47 -0
- data/lib/malipopay/resources/references.rb +46 -0
- data/lib/malipopay/resources/sms.rb +32 -0
- data/lib/malipopay/resources/transactions.rb +58 -0
- data/lib/malipopay/version.rb +5 -0
- data/lib/malipopay/webhooks/verifier.rb +69 -0
- data/lib/malipopay.rb +29 -0
- data/malipopay.gemspec +42 -0
- metadata +174 -0
data/docs/webhooks.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Webhooks
|
|
2
|
+
|
|
3
|
+
Webhooks let you receive real-time notifications when events happen in MaliPoPay -- payment completed, payment failed, disbursement processed, etc. Instead of polling the API, you register a URL and MaliPoPay sends HTTP POST requests to it.
|
|
4
|
+
|
|
5
|
+
## How Webhooks Work
|
|
6
|
+
|
|
7
|
+
1. You register a webhook URL in your MaliPoPay dashboard at [app.malipopay.co.tz](https://app.malipopay.co.tz) under **Settings > Webhooks**
|
|
8
|
+
2. MaliPoPay generates a **webhook signing secret** for you
|
|
9
|
+
3. When an event occurs, MaliPoPay sends a POST request to your URL with:
|
|
10
|
+
- The event payload as JSON in the request body
|
|
11
|
+
- An `X-MaliPoPay-Signature` header containing the HMAC-SHA256 signature
|
|
12
|
+
4. Your endpoint verifies the signature and processes the event
|
|
13
|
+
|
|
14
|
+
## Event Types
|
|
15
|
+
|
|
16
|
+
| Event | Description |
|
|
17
|
+
|-------|-------------|
|
|
18
|
+
| `payment.completed` | A collection was successfully completed |
|
|
19
|
+
| `payment.failed` | A collection failed (timeout, insufficient funds, cancelled) |
|
|
20
|
+
| `disbursement.completed` | A disbursement was sent successfully |
|
|
21
|
+
| `disbursement.failed` | A disbursement failed |
|
|
22
|
+
| `invoice.paid` | An invoice was fully paid |
|
|
23
|
+
| `invoice.partially_paid` | A partial payment was recorded |
|
|
24
|
+
|
|
25
|
+
## Sinatra Webhook Endpoint
|
|
26
|
+
|
|
27
|
+
A lightweight webhook endpoint using Sinatra:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require 'sinatra'
|
|
31
|
+
require 'json'
|
|
32
|
+
require 'malipopay'
|
|
33
|
+
|
|
34
|
+
WEBHOOK_SECRET = ENV.fetch('MALIPOPAY_WEBHOOK_SECRET')
|
|
35
|
+
verifier = MaliPoPay::Webhooks::Verifier.new(WEBHOOK_SECRET)
|
|
36
|
+
|
|
37
|
+
post '/webhooks/malipopay' do
|
|
38
|
+
payload = request.body.read
|
|
39
|
+
signature = request.env['HTTP_X_MALIPOPAY_SIGNATURE']
|
|
40
|
+
|
|
41
|
+
unless signature
|
|
42
|
+
halt 400, 'Missing signature'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
event = verifier.construct_event(payload, signature)
|
|
47
|
+
|
|
48
|
+
case event['event_type']
|
|
49
|
+
when 'payment.completed'
|
|
50
|
+
puts "Payment completed: #{event['reference']}, Amount: TZS #{event['amount']}"
|
|
51
|
+
# Update your order/invoice status in the database
|
|
52
|
+
|
|
53
|
+
when 'payment.failed'
|
|
54
|
+
puts "Payment failed: #{event['reference']}, Reason: #{event['reason']}"
|
|
55
|
+
# Notify the customer, retry, or cancel the order
|
|
56
|
+
|
|
57
|
+
when 'disbursement.completed'
|
|
58
|
+
puts "Disbursement sent: #{event['reference']}"
|
|
59
|
+
|
|
60
|
+
else
|
|
61
|
+
puts "Unhandled event type: #{event['event_type']}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
status 200
|
|
65
|
+
'OK'
|
|
66
|
+
rescue MaliPoPay::Error => e
|
|
67
|
+
puts "Webhook verification failed: #{e.message}"
|
|
68
|
+
halt 401, 'Invalid signature'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Rails Controller Example
|
|
74
|
+
|
|
75
|
+
A full Rails controller for handling MaliPoPay webhooks:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# app/controllers/webhooks/malipopay_controller.rb
|
|
79
|
+
module Webhooks
|
|
80
|
+
class MalipopayController < ApplicationController
|
|
81
|
+
skip_before_action :verify_authenticity_token
|
|
82
|
+
|
|
83
|
+
def create
|
|
84
|
+
payload = request.body.read
|
|
85
|
+
signature = request.headers['X-MaliPoPay-Signature']
|
|
86
|
+
|
|
87
|
+
unless signature.present?
|
|
88
|
+
render json: { error: 'Missing signature' }, status: :bad_request
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
event = webhook_verifier.construct_event(payload, signature)
|
|
94
|
+
handle_event(event)
|
|
95
|
+
head :ok
|
|
96
|
+
rescue MaliPoPay::Error => e
|
|
97
|
+
Rails.logger.error("Webhook verification failed: #{e.message}")
|
|
98
|
+
head :unauthorized
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def webhook_verifier
|
|
105
|
+
@webhook_verifier ||= MaliPoPay::Webhooks::Verifier.new(
|
|
106
|
+
ENV.fetch('MALIPOPAY_WEBHOOK_SECRET')
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_event(event)
|
|
111
|
+
case event['event_type']
|
|
112
|
+
when 'payment.completed'
|
|
113
|
+
handle_payment_completed(event)
|
|
114
|
+
when 'payment.failed'
|
|
115
|
+
handle_payment_failed(event)
|
|
116
|
+
when 'disbursement.completed'
|
|
117
|
+
Rails.logger.info("Disbursement completed: #{event['reference']}")
|
|
118
|
+
when 'invoice.paid'
|
|
119
|
+
handle_invoice_paid(event)
|
|
120
|
+
else
|
|
121
|
+
Rails.logger.info("Unhandled webhook event: #{event['event_type']}")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_payment_completed(event)
|
|
126
|
+
Rails.logger.info(
|
|
127
|
+
"Payment completed: #{event['reference']}, " \
|
|
128
|
+
"Amount: TZS #{event['amount']}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Update your order status
|
|
132
|
+
order = Order.find_by(reference: event['reference'])
|
|
133
|
+
order&.mark_as_paid!(
|
|
134
|
+
transaction_id: event['transaction_id'],
|
|
135
|
+
provider: event['provider']
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handle_payment_failed(event)
|
|
140
|
+
Rails.logger.warn(
|
|
141
|
+
"Payment failed: #{event['reference']}, " \
|
|
142
|
+
"Reason: #{event['reason']}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
order = Order.find_by(reference: event['reference'])
|
|
146
|
+
order&.mark_as_failed!(reason: event['reason'])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def handle_invoice_paid(event)
|
|
150
|
+
invoice = Invoice.find_by(external_id: event['reference'])
|
|
151
|
+
invoice&.mark_as_paid!
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Add the route in `config/routes.rb`:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
namespace :webhooks do
|
|
161
|
+
post 'malipopay', to: 'malipopay#create'
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Signature Verification
|
|
166
|
+
|
|
167
|
+
Every webhook request includes an `X-MaliPoPay-Signature` header. The signature is an HMAC-SHA256 hash of the raw request body, signed with your webhook secret.
|
|
168
|
+
|
|
169
|
+
The `MaliPoPay::Webhooks::Verifier` handles this for you:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
verifier = MaliPoPay::Webhooks::Verifier.new('your_webhook_secret')
|
|
173
|
+
|
|
174
|
+
# Just verify (returns true/false)
|
|
175
|
+
valid = verifier.verify(payload, signature)
|
|
176
|
+
|
|
177
|
+
# Verify and parse in one step (raises on failure)
|
|
178
|
+
event = verifier.construct_event(payload, signature)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Manual Verification
|
|
182
|
+
|
|
183
|
+
If you need to verify the signature manually without using the SDK:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
require 'openssl'
|
|
187
|
+
|
|
188
|
+
def verify_manually(payload, signature, secret)
|
|
189
|
+
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
|
|
190
|
+
Rack::Utils.secure_compare(expected, signature)
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Best Practices
|
|
195
|
+
|
|
196
|
+
1. **Always verify signatures.** Never process a webhook without checking the signature. This prevents spoofed requests.
|
|
197
|
+
|
|
198
|
+
2. **Return 200 quickly.** Process the event asynchronously if needed (e.g., with Sidekiq or ActiveJob). MaliPoPay expects a response within 30 seconds. If you don't return 200, the webhook will be retried.
|
|
199
|
+
|
|
200
|
+
3. **Handle duplicates.** Webhooks may be delivered more than once. Use the `reference` or `transaction_id` as an idempotency key.
|
|
201
|
+
|
|
202
|
+
4. **Log everything.** Log the raw payload and event type for debugging and audit trails.
|
|
203
|
+
|
|
204
|
+
5. **Use HTTPS.** Your webhook endpoint must be accessible over HTTPS in production.
|
|
205
|
+
|
|
206
|
+
6. **Process asynchronously in Rails.** Offload heavy work to a background job:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
def handle_payment_completed(event)
|
|
210
|
+
PaymentCompletedJob.perform_later(event.to_json)
|
|
211
|
+
# Return 200 immediately -- the job processes the event
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Next Steps
|
|
216
|
+
|
|
217
|
+
- [Error Handling](./error-handling.md) -- handle webhook verification failures
|
|
218
|
+
- [Payments](./payments.md) -- understand the payment flow that triggers webhooks
|
|
219
|
+
- [Configuration](./configuration.md) -- client setup
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaliPoPay
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :http_client, :webhook_secret
|
|
6
|
+
|
|
7
|
+
# Initialize a new MaliPoPay client
|
|
8
|
+
#
|
|
9
|
+
# @param api_key [String] Your MaliPoPay API token
|
|
10
|
+
# @param environment [Symbol] :production or :uat (default: :production)
|
|
11
|
+
# @param base_url [String, nil] Override the base URL
|
|
12
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
13
|
+
# @param retries [Integer] Number of retries on failure (default: 2)
|
|
14
|
+
# @param webhook_secret [String, nil] Secret for verifying webhooks
|
|
15
|
+
def initialize(api_key:, environment: :production, base_url: nil, timeout: 30, retries: 2, webhook_secret: nil)
|
|
16
|
+
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
17
|
+
|
|
18
|
+
@http_client = HttpClient.new(
|
|
19
|
+
api_key: api_key,
|
|
20
|
+
environment: environment,
|
|
21
|
+
base_url: base_url,
|
|
22
|
+
timeout: timeout,
|
|
23
|
+
retries: retries
|
|
24
|
+
)
|
|
25
|
+
@webhook_secret = webhook_secret
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [MaliPoPay::Resources::Payments]
|
|
29
|
+
def payments
|
|
30
|
+
@payments ||= Resources::Payments.new(@http_client)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [MaliPoPay::Resources::Customers]
|
|
34
|
+
def customers
|
|
35
|
+
@customers ||= Resources::Customers.new(@http_client)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [MaliPoPay::Resources::Invoices]
|
|
39
|
+
def invoices
|
|
40
|
+
@invoices ||= Resources::Invoices.new(@http_client)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [MaliPoPay::Resources::Products]
|
|
44
|
+
def products
|
|
45
|
+
@products ||= Resources::Products.new(@http_client)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [MaliPoPay::Resources::Transactions]
|
|
49
|
+
def transactions
|
|
50
|
+
@transactions ||= Resources::Transactions.new(@http_client)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [MaliPoPay::Resources::Account]
|
|
54
|
+
def account
|
|
55
|
+
@account ||= Resources::Account.new(@http_client)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [MaliPoPay::Resources::Sms]
|
|
59
|
+
def sms
|
|
60
|
+
@sms ||= Resources::Sms.new(@http_client)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [MaliPoPay::Resources::References]
|
|
64
|
+
def references
|
|
65
|
+
@references ||= Resources::References.new(@http_client)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [MaliPoPay::Webhooks::Verifier]
|
|
69
|
+
# @raise [ArgumentError] if webhook_secret was not provided
|
|
70
|
+
def webhooks
|
|
71
|
+
raise ArgumentError, "webhook_secret is required for webhook verification" unless @webhook_secret
|
|
72
|
+
|
|
73
|
+
@webhooks ||= Webhooks::Verifier.new(@webhook_secret)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaliPoPay
|
|
4
|
+
# Base error class for all MaliPoPay errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :http_status, :response_body
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, http_status: nil, response_body: nil)
|
|
9
|
+
@http_status = http_status
|
|
10
|
+
@response_body = response_body
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when the API key is missing or invalid (401)
|
|
16
|
+
class AuthenticationError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when the API key lacks permissions for the request (403)
|
|
19
|
+
class PermissionError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when the requested resource is not found (404)
|
|
22
|
+
class NotFoundError < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised when request parameters fail validation (400/422)
|
|
25
|
+
class ValidationError < Error
|
|
26
|
+
attr_reader :errors
|
|
27
|
+
|
|
28
|
+
def initialize(message = nil, errors: nil, **kwargs)
|
|
29
|
+
@errors = errors
|
|
30
|
+
super(message, **kwargs)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised when the API rate limit is exceeded (429)
|
|
35
|
+
class RateLimitError < Error
|
|
36
|
+
attr_reader :retry_after
|
|
37
|
+
|
|
38
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
39
|
+
@retry_after = retry_after
|
|
40
|
+
super(message, **kwargs)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raised for general API errors (5xx, unexpected responses)
|
|
45
|
+
class ApiError < Error; end
|
|
46
|
+
|
|
47
|
+
# Raised when a network connection error occurs
|
|
48
|
+
class ConnectionError < Error; end
|
|
49
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module MaliPoPay
|
|
8
|
+
class HttpClient
|
|
9
|
+
BASE_URLS = {
|
|
10
|
+
production: "https://core-prod.malipopay.co.tz",
|
|
11
|
+
uat: "https://core-uat.malipopay.co.tz"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(api_key:, environment: :production, base_url: nil, timeout: 30, retries: 2)
|
|
17
|
+
@api_key = api_key
|
|
18
|
+
@base_url = base_url || BASE_URLS.fetch(environment.to_sym) do
|
|
19
|
+
raise ArgumentError, "Invalid environment: #{environment}. Use :production or :uat"
|
|
20
|
+
end
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
@retries = retries
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(path, params: {})
|
|
26
|
+
execute(:get, path, params: params)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post(path, body: {})
|
|
30
|
+
execute(:post, path, body: body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def put(path, body: {})
|
|
34
|
+
execute(:put, path, body: body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(path, params: {})
|
|
38
|
+
execute(:delete, path, params: params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def connection
|
|
44
|
+
@connection ||= Faraday.new(url: @base_url) do |conn|
|
|
45
|
+
conn.request :json
|
|
46
|
+
conn.response :json, content_type: /\bjson$/
|
|
47
|
+
|
|
48
|
+
conn.request :retry,
|
|
49
|
+
max: @retries,
|
|
50
|
+
interval: 0.5,
|
|
51
|
+
interval_randomness: 0.5,
|
|
52
|
+
backoff_factor: 2,
|
|
53
|
+
retry_statuses: RETRYABLE_STATUS_CODES,
|
|
54
|
+
methods: %i[get post put delete],
|
|
55
|
+
retry_block: ->(env, _opts, _retries, _exc) {
|
|
56
|
+
env.request_headers["X-Retry-Count"] = _retries.to_s
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
conn.headers["apiToken"] = @api_key
|
|
60
|
+
conn.headers["Content-Type"] = "application/json"
|
|
61
|
+
conn.headers["Accept"] = "application/json"
|
|
62
|
+
conn.headers["User-Agent"] = "malipopay-ruby/#{MaliPoPay::VERSION}"
|
|
63
|
+
|
|
64
|
+
conn.options.timeout = @timeout
|
|
65
|
+
conn.options.open_timeout = 10
|
|
66
|
+
|
|
67
|
+
conn.adapter Faraday.default_adapter
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execute(method, path, params: {}, body: {})
|
|
72
|
+
response = case method
|
|
73
|
+
when :get
|
|
74
|
+
connection.get(path) { |req| req.params = params unless params.empty? }
|
|
75
|
+
when :post
|
|
76
|
+
connection.post(path, body)
|
|
77
|
+
when :put
|
|
78
|
+
connection.put(path, body)
|
|
79
|
+
when :delete
|
|
80
|
+
connection.delete(path) { |req| req.params = params unless params.empty? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
handle_response(response)
|
|
84
|
+
rescue Faraday::ConnectionFailed => e
|
|
85
|
+
raise MaliPoPay::ConnectionError.new("Connection failed: #{e.message}")
|
|
86
|
+
rescue Faraday::TimeoutError => e
|
|
87
|
+
raise MaliPoPay::ConnectionError.new("Request timed out: #{e.message}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_response(response)
|
|
91
|
+
case response.status
|
|
92
|
+
when 200..299
|
|
93
|
+
response.body
|
|
94
|
+
when 400
|
|
95
|
+
raise MaliPoPay::ValidationError.new(
|
|
96
|
+
error_message(response),
|
|
97
|
+
errors: response.body&.dig("errors"),
|
|
98
|
+
http_status: response.status,
|
|
99
|
+
response_body: response.body
|
|
100
|
+
)
|
|
101
|
+
when 401
|
|
102
|
+
raise MaliPoPay::AuthenticationError.new(
|
|
103
|
+
error_message(response),
|
|
104
|
+
http_status: response.status,
|
|
105
|
+
response_body: response.body
|
|
106
|
+
)
|
|
107
|
+
when 403
|
|
108
|
+
raise MaliPoPay::PermissionError.new(
|
|
109
|
+
error_message(response),
|
|
110
|
+
http_status: response.status,
|
|
111
|
+
response_body: response.body
|
|
112
|
+
)
|
|
113
|
+
when 404
|
|
114
|
+
raise MaliPoPay::NotFoundError.new(
|
|
115
|
+
error_message(response),
|
|
116
|
+
http_status: response.status,
|
|
117
|
+
response_body: response.body
|
|
118
|
+
)
|
|
119
|
+
when 422
|
|
120
|
+
raise MaliPoPay::ValidationError.new(
|
|
121
|
+
error_message(response),
|
|
122
|
+
errors: response.body&.dig("errors"),
|
|
123
|
+
http_status: response.status,
|
|
124
|
+
response_body: response.body
|
|
125
|
+
)
|
|
126
|
+
when 429
|
|
127
|
+
raise MaliPoPay::RateLimitError.new(
|
|
128
|
+
error_message(response),
|
|
129
|
+
retry_after: response.headers["Retry-After"]&.to_i,
|
|
130
|
+
http_status: response.status,
|
|
131
|
+
response_body: response.body
|
|
132
|
+
)
|
|
133
|
+
else
|
|
134
|
+
raise MaliPoPay::ApiError.new(
|
|
135
|
+
error_message(response),
|
|
136
|
+
http_status: response.status,
|
|
137
|
+
response_body: response.body
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def error_message(response)
|
|
143
|
+
body = response.body
|
|
144
|
+
if body.is_a?(Hash)
|
|
145
|
+
body["message"] || body["error"] || "API error (#{response.status})"
|
|
146
|
+
else
|
|
147
|
+
"API error (#{response.status})"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaliPoPay
|
|
4
|
+
module Resources
|
|
5
|
+
class Account
|
|
6
|
+
def initialize(http_client)
|
|
7
|
+
@http = http_client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# List all account transactions
|
|
11
|
+
# @param params [Hash] Query parameters (page, limit, dateFrom, dateTo, etc.)
|
|
12
|
+
# @return [Hash] Paginated list of account transactions
|
|
13
|
+
def transactions(params = {})
|
|
14
|
+
@http.get("/api/v1/account/allTransaction", params: params)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Search account transactions
|
|
18
|
+
# @param params [Hash] Search parameters
|
|
19
|
+
# @return [Hash] Search results
|
|
20
|
+
def search_transactions(params = {})
|
|
21
|
+
@http.get("/api/v1/account/allTransaction", params: params)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get account reconciliation data
|
|
25
|
+
# @param params [Hash] Query parameters (dateFrom, dateTo, etc.)
|
|
26
|
+
# @return [Hash] Reconciliation data
|
|
27
|
+
def reconciliation(params = {})
|
|
28
|
+
@http.get("/api/v1/account/reconciliation", params: params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get financial position report
|
|
32
|
+
# @param params [Hash] Query parameters
|
|
33
|
+
# @return [Hash] Financial position data
|
|
34
|
+
def financial_position(params = {})
|
|
35
|
+
@http.get("/api/v1/account/allTransaction", params: params.merge(report: "financial_position"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get income statement
|
|
39
|
+
# @param params [Hash] Query parameters
|
|
40
|
+
# @return [Hash] Income statement data
|
|
41
|
+
def income_statement(params = {})
|
|
42
|
+
@http.get("/api/v1/account/allTransaction", params: params.merge(report: "income_statement"))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get general ledger
|
|
46
|
+
# @param params [Hash] Query parameters
|
|
47
|
+
# @return [Hash] General ledger data
|
|
48
|
+
def general_ledger(params = {})
|
|
49
|
+
@http.get("/api/v1/account/allTransaction", params: params.merge(report: "general_ledger"))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get trial balance
|
|
53
|
+
# @param params [Hash] Query parameters
|
|
54
|
+
# @return [Hash] Trial balance data
|
|
55
|
+
def trial_balance(params = {})
|
|
56
|
+
@http.get("/api/v1/account/allTransaction", params: params.merge(report: "trial_balance"))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaliPoPay
|
|
4
|
+
module Resources
|
|
5
|
+
class Customers
|
|
6
|
+
def initialize(http_client)
|
|
7
|
+
@http = http_client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Create a new customer
|
|
11
|
+
# @param params [Hash] Customer parameters (name, phone, email, etc.)
|
|
12
|
+
# @return [Hash] Created customer
|
|
13
|
+
def create(params)
|
|
14
|
+
@http.post("/api/v1/customer", body: params)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List all customers
|
|
18
|
+
# @param params [Hash] Query parameters (page, limit, etc.)
|
|
19
|
+
# @return [Hash] Paginated list of customers
|
|
20
|
+
def list(params = {})
|
|
21
|
+
@http.get("/api/v1/customer", params: params)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get a customer by ID
|
|
25
|
+
# @param id [String] Customer ID
|
|
26
|
+
# @return [Hash] Customer details
|
|
27
|
+
def get(id)
|
|
28
|
+
@http.get("/api/v1/customer/#{id}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get a customer by customer number
|
|
32
|
+
# @param number [String] Customer number
|
|
33
|
+
# @return [Hash] Customer details
|
|
34
|
+
def get_by_number(number)
|
|
35
|
+
@http.get("/api/v1/customer/search", params: { customerNumber: number })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get a customer by phone number
|
|
39
|
+
# @param phone [String] Phone number
|
|
40
|
+
# @return [Hash] Customer details
|
|
41
|
+
def get_by_phone(phone)
|
|
42
|
+
@http.get("/api/v1/customer/search", params: { phone: phone })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Search customers
|
|
46
|
+
# @param params [Hash] Search parameters
|
|
47
|
+
# @return [Hash] Search results
|
|
48
|
+
def search(params = {})
|
|
49
|
+
@http.get("/api/v1/customer/search", params: params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Verify a customer
|
|
53
|
+
# @param params [Hash] Verification parameters
|
|
54
|
+
# @return [Hash] Verification response
|
|
55
|
+
def verify_customer(params)
|
|
56
|
+
@http.post("/api/v1/customer/verify", body: params)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaliPoPay
|
|
4
|
+
module Resources
|
|
5
|
+
class Invoices
|
|
6
|
+
def initialize(http_client)
|
|
7
|
+
@http = http_client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Create a new invoice
|
|
11
|
+
# @param params [Hash] Invoice parameters
|
|
12
|
+
# @return [Hash] Created invoice
|
|
13
|
+
def create(params)
|
|
14
|
+
@http.post("/api/v1/invoice", body: params)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List all invoices
|
|
18
|
+
# @param params [Hash] Query parameters (page, limit, status, etc.)
|
|
19
|
+
# @return [Hash] Paginated list of invoices
|
|
20
|
+
def list(params = {})
|
|
21
|
+
@http.get("/api/v1/invoice", params: params)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get an invoice by ID
|
|
25
|
+
# @param id [String] Invoice ID
|
|
26
|
+
# @return [Hash] Invoice details
|
|
27
|
+
def get(id)
|
|
28
|
+
@http.get("/api/v1/invoice/#{id}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get an invoice by invoice number
|
|
32
|
+
# @param number [String] Invoice number
|
|
33
|
+
# @return [Hash] Invoice details
|
|
34
|
+
def get_by_number(number)
|
|
35
|
+
@http.get("/api/v1/invoice", params: { invoiceNumber: number })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Update an existing invoice
|
|
39
|
+
# @param id [String] Invoice ID
|
|
40
|
+
# @param params [Hash] Updated invoice parameters
|
|
41
|
+
# @return [Hash] Updated invoice
|
|
42
|
+
def update(id, params)
|
|
43
|
+
@http.put("/api/v1/invoice/#{id}", body: params)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Record a payment against an invoice
|
|
47
|
+
# @param params [Hash] Payment parameters (invoiceId, amount, reference, etc.)
|
|
48
|
+
# @return [Hash] Payment record response
|
|
49
|
+
def record_payment(params)
|
|
50
|
+
@http.post("/api/v1/invoice/record-payment", body: params)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Approve a draft invoice
|
|
54
|
+
# @param params [Hash] Approval parameters (invoiceId, etc.)
|
|
55
|
+
# @return [Hash] Approval response
|
|
56
|
+
def approve_draft(params)
|
|
57
|
+
@http.post("/api/v1/invoice/approve-draft", body: params)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|