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.
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