sunny-payments 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 487454dfe36081782721d77218b1a9e9e1538cbbcef624b57d01a2ea68d70c7e
4
+ data.tar.gz: ca5c9b4f2292e796b7e14a11e381526c351d05c8db5fda9cf06fc64ac6976db3
5
+ SHA512:
6
+ metadata.gz: 72ca1ceda8b61446b111c8fc51e8a1f62d176fbc311d290c972525d13dc43389c771acccf53f007c2010934b9ed33793caac3b73a52687983f70fa517f6757a4
7
+ data.tar.gz: f8309afac75958f824151c70a250d5735f387c06a5f8ac219b27de559b84e23f1b8a9cfe99e67f066d14e61eda630bfaa5a4307e11ff8d826081c7b7a406e2bf
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Sunny Pay Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ <p align="center">
2
+ <img src="assets/sunny-logo.svg" alt="Sunny Payments" width="60" height="60">
3
+ <img src="assets/ruby.svg" alt="Ruby" width="60" height="60">
4
+ </p>
5
+
6
+ <h1 align="center">Sunny Payments Ruby SDK</h1>
7
+
8
+ <p align="center">
9
+ The official Ruby SDK for <a href="https://sunnypay.co.ke">Sunny Payments</a> - Payment processing made simple.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://rubygems.org/gems/sunny-payments"><img src="https://img.shields.io/gem/v/sunny-payments.svg" alt="Gem Version"></a>
14
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
15
+ </p>
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ gem install sunny-payments
21
+ ```
22
+
23
+ Or add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'sunny-payments', '~> 1.0'
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```ruby
32
+ require 'sunny'
33
+
34
+ # Initialize the client
35
+ sunny = Sunny::Client.new('sk_live_your_api_key')
36
+
37
+ # Create a payment (currency is REQUIRED)
38
+ payment = sunny.payments.create(
39
+ amount: 1000,
40
+ currency: 'KES', # Required - no default!
41
+ source: 'mpesa',
42
+ description: 'Order #12345'
43
+ )
44
+
45
+ puts payment['id'] # pay_xxxxx
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - ✅ **Payments** - Create, capture, refund payments
51
+ - ✅ **Mobile Money** - M-Pesa STK Push, B2C, MTN MoMo, Airtel Money
52
+ - ✅ **Invoices** - Create and send invoices
53
+ - ✅ **Bills** - Airtime, data, electricity payments
54
+ - ✅ **QR Codes** - Static and dynamic QR payments
55
+ - ✅ **Crypto** - Accept BTC, ETH, USDT, USDC
56
+ - ✅ **Webhooks** - Register endpoints & verify signatures
57
+ - ✅ **BNPL** - Buy Now Pay Later
58
+
59
+ ## Usage Examples
60
+
61
+ ### M-Pesa STK Push
62
+
63
+ ```ruby
64
+ result = sunny.mobile_money.mpesa_stk_push(
65
+ phone_number: '254712345678',
66
+ amount: 1000,
67
+ currency: 'KES', # Required
68
+ account_reference: 'ORDER-001'
69
+ )
70
+
71
+ # Check status
72
+ status = sunny.mobile_money.mpesa_status(result['checkout_request_id'])
73
+ ```
74
+
75
+ ### Bills
76
+
77
+ ```ruby
78
+ # Purchase airtime
79
+ sunny.bills.purchase_airtime(
80
+ phone_number: '+254712345678',
81
+ amount: 100,
82
+ network: 'safaricom'
83
+ )
84
+
85
+ # Buy electricity tokens
86
+ electricity = sunny.bills.purchase_electricity(
87
+ meter_number: '12345678',
88
+ amount: 500,
89
+ phone_number: '+254712345678'
90
+ )
91
+ puts electricity['token'] # KPLC token
92
+ ```
93
+
94
+ ### Crypto Payments
95
+
96
+ ```ruby
97
+ address = sunny.crypto.create_address(
98
+ cryptocurrency: 'USDT',
99
+ amount: 10000,
100
+ currency: 'KES' # Required
101
+ )
102
+ ```
103
+
104
+ ### Webhooks
105
+
106
+ ```ruby
107
+ # Register webhook
108
+ webhook = sunny.webhooks.create(
109
+ url: 'https://example.com/webhooks/sunny',
110
+ events: ['payment.succeeded', 'payment.failed']
111
+ )
112
+
113
+ # In your Sinatra/Rails controller:
114
+ post '/webhooks/sunny' do
115
+ payload = request.body.read
116
+ signature = request.env['HTTP_X_SUNNY_SIGNATURE']
117
+
118
+ unless Sunny::Resources::Webhooks.verify_signature(payload, signature, WEBHOOK_SECRET)
119
+ halt 400, 'Invalid signature'
120
+ end
121
+
122
+ event = JSON.parse(payload)
123
+
124
+ case event['type']
125
+ when 'payment.succeeded'
126
+ # Handle successful payment
127
+ end
128
+
129
+ status 200
130
+ 'OK'
131
+ end
132
+ ```
133
+
134
+ ## Error Handling
135
+
136
+ ```ruby
137
+ begin
138
+ payment = sunny.payments.create(amount: 1000, currency: 'KES', source: 'mpesa')
139
+ rescue Sunny::AuthenticationError
140
+ puts "Invalid API key"
141
+ rescue Sunny::ValidationError => e
142
+ puts "Validation error: #{e.message}, field: #{e.field}"
143
+ rescue Sunny::RateLimitError => e
144
+ puts "Rate limited. Retry after #{e.retry_after} seconds"
145
+ rescue Sunny::APIError => e
146
+ puts "API error: #{e.message}"
147
+ end
148
+ ```
149
+
150
+ ## Configuration
151
+
152
+ ```ruby
153
+ # Via environment variable
154
+ ENV['SUNNY_API_KEY'] = 'sk_live_xxx'
155
+
156
+ # Or configure directly
157
+ sunny = Sunny::Client.new('sk_live_xxx', {
158
+ base_url: 'https://api.sunnypay.co.ke/v1',
159
+ timeout: 30
160
+ })
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Sunny
7
+ # Main client for interacting with Sunny Payments API
8
+ class Client
9
+ attr_reader :payments, :customers, :mobile_money, :invoices, :bills,
10
+ :qr_codes, :crypto, :webhooks, :refunds, :virtual_accounts, :bnpl
11
+
12
+ # Initialize a new Sunny client
13
+ #
14
+ # @param api_key [String] Your Sunny API key (starts with sk_live_ or sk_test_)
15
+ # @param options [Hash] Optional configuration
16
+ # @option options [String] :base_url Custom API base URL
17
+ # @option options [Integer] :timeout Request timeout in seconds (default: 30)
18
+ def initialize(api_key, options = {})
19
+ raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
20
+ raise AuthenticationError, "Invalid API key format" unless api_key.start_with?("sk_")
21
+
22
+ @api_key = api_key
23
+ @base_url = options[:base_url] || Sunny.api_base
24
+ @timeout = options[:timeout] || 30
25
+
26
+ @connection = build_connection
27
+
28
+ # Initialize resources
29
+ @payments = Resources::Payments.new(self)
30
+ @customers = Resources::Customers.new(self)
31
+ @mobile_money = Resources::MobileMoney.new(self)
32
+ @invoices = Resources::Invoices.new(self)
33
+ @bills = Resources::Bills.new(self)
34
+ @qr_codes = Resources::QRCodes.new(self)
35
+ @crypto = Resources::Crypto.new(self)
36
+ @webhooks = Resources::Webhooks.new(self)
37
+ @refunds = Resources::Refunds.new(self)
38
+ @virtual_accounts = Resources::VirtualAccounts.new(self)
39
+ @bnpl = Resources::BNPL.new(self)
40
+ end
41
+
42
+ # Make a GET request
43
+ def get(path, params = {})
44
+ request(:get, path, params)
45
+ end
46
+
47
+ # Make a POST request
48
+ def post(path, body = {})
49
+ request(:post, path, body)
50
+ end
51
+
52
+ # Make a PUT request
53
+ def put(path, body = {})
54
+ request(:put, path, body)
55
+ end
56
+
57
+ # Make a DELETE request
58
+ def delete(path)
59
+ request(:delete, path)
60
+ end
61
+
62
+ private
63
+
64
+ def build_connection
65
+ Faraday.new(url: @base_url) do |conn|
66
+ conn.request :json
67
+ conn.response :json, content_type: /\bjson$/
68
+ conn.headers["Authorization"] = "Bearer #{@api_key}"
69
+ conn.headers["Content-Type"] = "application/json"
70
+ conn.headers["User-Agent"] = "sunny-ruby/#{VERSION}"
71
+ conn.options.timeout = @timeout
72
+ conn.adapter Faraday.default_adapter
73
+ end
74
+ end
75
+
76
+ def request(method, path, data = nil)
77
+ response = @connection.send(method, path, data)
78
+ handle_response(response)
79
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
80
+ raise NetworkError, e.message
81
+ end
82
+
83
+ def handle_response(response)
84
+ case response.status
85
+ when 200..299
86
+ response.body
87
+ when 401
88
+ raise AuthenticationError, error_message(response)
89
+ when 400
90
+ raise ValidationError.new(error_message(response), field: response.body&.dig("field"))
91
+ when 429
92
+ retry_after = response.headers["Retry-After"]&.to_i || 60
93
+ raise RateLimitError, retry_after
94
+ else
95
+ raise APIError.new(error_message(response), status: response.status, code: response.body&.dig("code"))
96
+ end
97
+ end
98
+
99
+ def error_message(response)
100
+ response.body&.dig("message") || response.body&.dig("error") || "An error occurred"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ # Base error class for all Sunny errors
5
+ class SunnyError < StandardError
6
+ attr_reader :code, :status
7
+
8
+ def initialize(message = nil, code: nil, status: nil)
9
+ @code = code
10
+ @status = status
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # Raised when API key is invalid or missing
16
+ class AuthenticationError < SunnyError
17
+ def initialize(message = "Invalid API key")
18
+ super(message, status: 401)
19
+ end
20
+ end
21
+
22
+ # Raised when request parameters are invalid
23
+ class ValidationError < SunnyError
24
+ attr_reader :field
25
+
26
+ def initialize(message, field: nil)
27
+ @field = field
28
+ super(message, status: 400)
29
+ end
30
+ end
31
+
32
+ # Raised when rate limit is exceeded
33
+ class RateLimitError < SunnyError
34
+ attr_reader :retry_after
35
+
36
+ def initialize(retry_after = 60)
37
+ @retry_after = retry_after
38
+ super("Rate limit exceeded. Retry after #{retry_after} seconds", status: 429)
39
+ end
40
+ end
41
+
42
+ # Raised for general API errors
43
+ class APIError < SunnyError
44
+ def initialize(message, status: nil, code: nil)
45
+ super(message, status: status, code: code)
46
+ end
47
+ end
48
+
49
+ # Raised for network/connection errors
50
+ class NetworkError < SunnyError
51
+ def initialize(message = "Unable to connect to Sunny API")
52
+ super(message)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Bills resource for airtime, data, electricity
6
+ class Bills
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Purchase airtime
12
+ #
13
+ # @param phone_number [String] Phone number to top up
14
+ # @param amount [Integer] Amount to purchase
15
+ # @param network [String] Network provider (safaricom, airtel, telkom)
16
+ def purchase_airtime(phone_number:, amount:, network:)
17
+ @client.post("/bills/airtime", {
18
+ phone_number: phone_number,
19
+ amount: amount,
20
+ network: network
21
+ })
22
+ end
23
+
24
+ # Get available data bundles
25
+ def get_data_bundles(network)
26
+ @client.get("/bills/data/bundles", { network: network })
27
+ end
28
+
29
+ # Purchase data bundle
30
+ def purchase_data(phone_number:, bundle_id:, network:)
31
+ @client.post("/bills/data", {
32
+ phone_number: phone_number,
33
+ bundle_id: bundle_id,
34
+ network: network
35
+ })
36
+ end
37
+
38
+ # Purchase electricity tokens (KPLC)
39
+ def purchase_electricity(meter_number:, amount:, phone_number:)
40
+ @client.post("/bills/electricity", {
41
+ meter_number: meter_number,
42
+ amount: amount,
43
+ phone_number: phone_number
44
+ })
45
+ end
46
+
47
+ # Validate electricity meter
48
+ def validate_meter(meter_number)
49
+ @client.get("/bills/electricity/validate", { meter_number: meter_number })
50
+ end
51
+
52
+ # Pay TV subscription
53
+ def pay_tv(provider:, account_number:, amount:, phone_number:)
54
+ @client.post("/bills/tv", {
55
+ provider: provider,
56
+ account_number: account_number,
57
+ amount: amount,
58
+ phone_number: phone_number
59
+ })
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # BNPL (Buy Now Pay Later) resource
6
+ class BNPL
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Check customer eligibility
12
+ #
13
+ # @param customer_id [String] Customer ID
14
+ # @param amount [Integer] Amount to check
15
+ # @param currency [String] Currency code - REQUIRED
16
+ def check_eligibility(customer_id:, amount:, currency:)
17
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
18
+
19
+ @client.post("/bnpl/eligibility", {
20
+ customer_id: customer_id,
21
+ amount: amount,
22
+ currency: currency
23
+ })
24
+ end
25
+
26
+ # Create a BNPL payment plan
27
+ def create_plan(customer_id:, amount:, currency:, installments:, **options)
28
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
29
+
30
+ @client.post("/bnpl/plans", {
31
+ customer_id: customer_id,
32
+ amount: amount,
33
+ currency: currency,
34
+ installments: installments,
35
+ **options
36
+ })
37
+ end
38
+
39
+ # Get a payment plan
40
+ def retrieve_plan(plan_id)
41
+ @client.get("/bnpl/plans/#{plan_id}")
42
+ end
43
+
44
+ # List payment plans
45
+ def list_plans(**params)
46
+ @client.get("/bnpl/plans", params)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Crypto resource for crypto payments
6
+ class Crypto
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a crypto payment address
12
+ #
13
+ # @param cryptocurrency [String] Crypto type (BTC, ETH, USDT, USDC)
14
+ # @param amount [Integer] Expected amount in fiat
15
+ # @param currency [String] Fiat currency code - REQUIRED
16
+ # @param options [Hash] Additional options
17
+ def create_address(cryptocurrency:, amount:, currency:, **options)
18
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
19
+
20
+ @client.post("/crypto/addresses", {
21
+ cryptocurrency: cryptocurrency,
22
+ amount: amount,
23
+ currency: currency,
24
+ **options
25
+ })
26
+ end
27
+
28
+ # Get crypto payment status
29
+ def get_payment(payment_id)
30
+ @client.get("/crypto/payments/#{payment_id}")
31
+ end
32
+
33
+ # List crypto payments
34
+ def list_payments(**params)
35
+ @client.get("/crypto/payments", params)
36
+ end
37
+
38
+ # Get exchange rates
39
+ def get_rates(cryptocurrency:, currency:)
40
+ @client.get("/crypto/rates", {
41
+ cryptocurrency: cryptocurrency,
42
+ currency: currency
43
+ })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Customers resource
6
+ class Customers
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a new customer
12
+ def create(email:, **options)
13
+ @client.post("/customers", { email: email, **options })
14
+ end
15
+
16
+ # Retrieve a customer
17
+ def retrieve(customer_id)
18
+ @client.get("/customers/#{customer_id}")
19
+ end
20
+
21
+ # Update a customer
22
+ def update(customer_id, **attributes)
23
+ @client.put("/customers/#{customer_id}", attributes)
24
+ end
25
+
26
+ # List customers
27
+ def list(**params)
28
+ @client.get("/customers", params)
29
+ end
30
+
31
+ # Delete a customer
32
+ def delete(customer_id)
33
+ @client.delete("/customers/#{customer_id}")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Invoices resource
6
+ class Invoices
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a new invoice
12
+ #
13
+ # @param customer_email [String] Customer email
14
+ # @param amount [Integer] Invoice amount
15
+ # @param currency [String] Currency code - REQUIRED
16
+ # @param options [Hash] Additional options
17
+ def create(customer_email:, amount:, currency:, **options)
18
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
19
+
20
+ @client.post("/invoices", {
21
+ customer_email: customer_email,
22
+ amount: amount,
23
+ currency: currency,
24
+ **options
25
+ })
26
+ end
27
+
28
+ # Retrieve an invoice
29
+ def retrieve(invoice_id)
30
+ @client.get("/invoices/#{invoice_id}")
31
+ end
32
+
33
+ # List invoices
34
+ def list(**params)
35
+ @client.get("/invoices", params)
36
+ end
37
+
38
+ # Send an invoice
39
+ def send_invoice(invoice_id)
40
+ @client.post("/invoices/#{invoice_id}/send", {})
41
+ end
42
+
43
+ # Mark invoice as paid
44
+ def mark_paid(invoice_id)
45
+ @client.post("/invoices/#{invoice_id}/mark-paid", {})
46
+ end
47
+
48
+ # Void an invoice
49
+ def void(invoice_id)
50
+ @client.post("/invoices/#{invoice_id}/void", {})
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Mobile Money resource for M-Pesa, MTN MoMo, Airtel Money
6
+ class MobileMoney
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Initiate M-Pesa STK Push
12
+ #
13
+ # @param phone_number [String] Customer phone number (254...)
14
+ # @param amount [Integer] Amount in smallest currency unit
15
+ # @param currency [String] Currency code (e.g., 'KES') - REQUIRED
16
+ # @param account_reference [String] Account reference for the transaction
17
+ # @param options [Hash] Additional options (description, metadata, etc.)
18
+ # @return [Hash] STK push response with checkout_request_id
19
+ def mpesa_stk_push(phone_number:, amount:, currency:, account_reference:, **options)
20
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
21
+
22
+ @client.post("/mobile-money/mpesa/stk-push", {
23
+ phone_number: phone_number,
24
+ amount: amount,
25
+ currency: currency,
26
+ account_reference: account_reference,
27
+ **options
28
+ })
29
+ end
30
+
31
+ # Check M-Pesa STK Push status
32
+ #
33
+ # @param checkout_request_id [String] The checkout request ID from stk_push
34
+ # @return [Hash] Transaction status
35
+ def mpesa_status(checkout_request_id)
36
+ @client.get("/mobile-money/mpesa/status/#{checkout_request_id}")
37
+ end
38
+
39
+ # M-Pesa B2C (payouts)
40
+ #
41
+ # @param phone_number [String] Recipient phone number
42
+ # @param amount [Integer] Amount to send
43
+ # @param currency [String] Currency code - REQUIRED
44
+ # @param options [Hash] Additional options
45
+ # @return [Hash] B2C response
46
+ def mpesa_b2c(phone_number:, amount:, currency:, **options)
47
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
48
+
49
+ @client.post("/mobile-money/mpesa/b2c", {
50
+ phone_number: phone_number,
51
+ amount: amount,
52
+ currency: currency,
53
+ **options
54
+ })
55
+ end
56
+
57
+ # MTN Mobile Money collection
58
+ #
59
+ # @param phone_number [String] Customer phone number
60
+ # @param amount [Integer] Amount to collect
61
+ # @param currency [String] Currency code (e.g., 'UGX') - REQUIRED
62
+ # @param options [Hash] Additional options
63
+ # @return [Hash] Collection response
64
+ def mtn_collect(phone_number:, amount:, currency:, **options)
65
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
66
+
67
+ @client.post("/mobile-money/mtn/collect", {
68
+ phone_number: phone_number,
69
+ amount: amount,
70
+ currency: currency,
71
+ **options
72
+ })
73
+ end
74
+
75
+ # Airtel Money collection
76
+ #
77
+ # @param phone_number [String] Customer phone number
78
+ # @param amount [Integer] Amount to collect
79
+ # @param currency [String] Currency code - REQUIRED
80
+ # @param options [Hash] Additional options
81
+ # @return [Hash] Collection response
82
+ def airtel_collect(phone_number:, amount:, currency:, **options)
83
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
84
+
85
+ @client.post("/mobile-money/airtel/collect", {
86
+ phone_number: phone_number,
87
+ amount: amount,
88
+ currency: currency,
89
+ **options
90
+ })
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Payments resource for creating and managing payments
6
+ class Payments
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a new payment
12
+ #
13
+ # @param amount [Integer] Amount in smallest currency unit
14
+ # @param currency [String] Currency code (e.g., 'KES', 'USD', 'UGX') - REQUIRED
15
+ # @param source [String] Payment source (e.g., 'mpesa', 'card')
16
+ # @param options [Hash] Additional options
17
+ # @return [Hash] Created payment object
18
+ def create(amount:, currency:, source:, **options)
19
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
20
+
21
+ @client.post("/payments", {
22
+ amount: amount,
23
+ currency: currency,
24
+ source: source,
25
+ **options
26
+ })
27
+ end
28
+
29
+ # Retrieve a payment by ID
30
+ #
31
+ # @param payment_id [String] The payment ID
32
+ # @return [Hash] Payment object
33
+ def retrieve(payment_id)
34
+ @client.get("/payments/#{payment_id}")
35
+ end
36
+
37
+ # List payments
38
+ #
39
+ # @param params [Hash] Query parameters (limit, starting_after, etc.)
40
+ # @return [Hash] List of payments with pagination info
41
+ def list(**params)
42
+ @client.get("/payments", params)
43
+ end
44
+
45
+ # Capture a payment
46
+ #
47
+ # @param payment_id [String] The payment ID
48
+ # @param amount [Integer, nil] Amount to capture (optional, captures full amount if nil)
49
+ # @return [Hash] Captured payment object
50
+ def capture(payment_id, amount: nil)
51
+ body = amount ? { amount: amount } : {}
52
+ @client.post("/payments/#{payment_id}/capture", body)
53
+ end
54
+
55
+ # Refund a payment
56
+ #
57
+ # @param payment_id [String] The payment ID
58
+ # @param amount [Integer, nil] Amount to refund (optional, full refund if nil)
59
+ # @param reason [String, nil] Reason for refund
60
+ # @return [Hash] Refund object
61
+ def refund(payment_id, amount: nil, reason: nil)
62
+ body = {}
63
+ body[:amount] = amount if amount
64
+ body[:reason] = reason if reason
65
+ @client.post("/payments/#{payment_id}/refund", body)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # QR Codes resource
6
+ class QRCodes
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a QR code
12
+ #
13
+ # @param type [String] QR type ('static' or 'dynamic')
14
+ # @param amount [Integer, nil] Amount (required for dynamic)
15
+ # @param currency [String] Currency code - REQUIRED
16
+ # @param options [Hash] Additional options
17
+ def create(type:, currency:, amount: nil, **options)
18
+ raise ValidationError.new("currency is required", field: "currency") if currency.nil? || currency.empty?
19
+
20
+ body = { type: type, currency: currency, **options }
21
+ body[:amount] = amount if amount
22
+ @client.post("/qr-codes", body)
23
+ end
24
+
25
+ # Retrieve a QR code
26
+ def retrieve(qr_id)
27
+ @client.get("/qr-codes/#{qr_id}")
28
+ end
29
+
30
+ # List QR codes
31
+ def list(**params)
32
+ @client.get("/qr-codes", params)
33
+ end
34
+
35
+ # Deactivate a QR code
36
+ def deactivate(qr_id)
37
+ @client.post("/qr-codes/#{qr_id}/deactivate", {})
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Refunds resource
6
+ class Refunds
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a refund
12
+ def create(payment_id:, amount: nil, reason: nil)
13
+ body = { payment_id: payment_id }
14
+ body[:amount] = amount if amount
15
+ body[:reason] = reason if reason
16
+ @client.post("/refunds", body)
17
+ end
18
+
19
+ # Retrieve a refund
20
+ def retrieve(refund_id)
21
+ @client.get("/refunds/#{refund_id}")
22
+ end
23
+
24
+ # List refunds
25
+ def list(**params)
26
+ @client.get("/refunds", params)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ module Resources
5
+ # Virtual Accounts resource
6
+ class VirtualAccounts
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a virtual account
12
+ def create(customer_email:, **options)
13
+ @client.post("/virtual-accounts", {
14
+ customer_email: customer_email,
15
+ **options
16
+ })
17
+ end
18
+
19
+ # Retrieve a virtual account
20
+ def retrieve(account_id)
21
+ @client.get("/virtual-accounts/#{account_id}")
22
+ end
23
+
24
+ # List virtual accounts
25
+ def list(**params)
26
+ @client.get("/virtual-accounts", params)
27
+ end
28
+
29
+ # Close a virtual account
30
+ def close(account_id)
31
+ @client.post("/virtual-accounts/#{account_id}/close", {})
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Sunny
6
+ module Resources
7
+ # Webhooks resource
8
+ class Webhooks
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # Register a webhook endpoint
14
+ def create(url:, events:, **options)
15
+ @client.post("/webhooks", {
16
+ url: url,
17
+ events: events,
18
+ **options
19
+ })
20
+ end
21
+
22
+ # Retrieve a webhook
23
+ def retrieve(webhook_id)
24
+ @client.get("/webhooks/#{webhook_id}")
25
+ end
26
+
27
+ # List webhooks
28
+ def list(**params)
29
+ @client.get("/webhooks", params)
30
+ end
31
+
32
+ # Update a webhook
33
+ def update(webhook_id, **attributes)
34
+ @client.put("/webhooks/#{webhook_id}", attributes)
35
+ end
36
+
37
+ # Delete a webhook
38
+ def delete(webhook_id)
39
+ @client.delete("/webhooks/#{webhook_id}")
40
+ end
41
+
42
+ # Verify webhook signature
43
+ #
44
+ # @param payload [String] Raw request body
45
+ # @param signature [String] X-Sunny-Signature header value
46
+ # @param secret [String] Your webhook secret
47
+ # @return [Boolean] Whether signature is valid
48
+ def self.verify_signature(payload, signature, secret)
49
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
50
+ secure_compare(expected, signature)
51
+ end
52
+
53
+ # Constant-time string comparison
54
+ def self.secure_compare(a, b)
55
+ return false unless a.bytesize == b.bytesize
56
+
57
+ result = 0
58
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
59
+ result.zero?
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunny
4
+ VERSION = "1.0.0"
5
+ end
data/lib/sunny.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sunny/version"
4
+ require_relative "sunny/errors"
5
+ require_relative "sunny/client"
6
+ require_relative "sunny/resources/payments"
7
+ require_relative "sunny/resources/customers"
8
+ require_relative "sunny/resources/mobile_money"
9
+ require_relative "sunny/resources/invoices"
10
+ require_relative "sunny/resources/bills"
11
+ require_relative "sunny/resources/qr_codes"
12
+ require_relative "sunny/resources/crypto"
13
+ require_relative "sunny/resources/webhooks"
14
+ require_relative "sunny/resources/refunds"
15
+ require_relative "sunny/resources/virtual_accounts"
16
+ require_relative "sunny/resources/bnpl"
17
+
18
+ module Sunny
19
+ class << self
20
+ attr_accessor :api_key, :api_base
21
+
22
+ def configure
23
+ yield self
24
+ end
25
+ end
26
+
27
+ self.api_base = ENV["SUNNY_API_URL"] || "https://api.sunnypay.co.ke/v1"
28
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sunny-payments
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Sunny Pay Team
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-retry
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rspec
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '3.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rubocop
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '1.0'
88
+ description: Accept Mobile money, cards, crypto, invoices, QR codes, and more with
89
+ Sunny Payments.
90
+ email:
91
+ - dev@sunnypay.co.ke
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - LICENSE
97
+ - README.md
98
+ - lib/sunny.rb
99
+ - lib/sunny/client.rb
100
+ - lib/sunny/errors.rb
101
+ - lib/sunny/resources/bills.rb
102
+ - lib/sunny/resources/bnpl.rb
103
+ - lib/sunny/resources/crypto.rb
104
+ - lib/sunny/resources/customers.rb
105
+ - lib/sunny/resources/invoices.rb
106
+ - lib/sunny/resources/mobile_money.rb
107
+ - lib/sunny/resources/payments.rb
108
+ - lib/sunny/resources/qr_codes.rb
109
+ - lib/sunny/resources/refunds.rb
110
+ - lib/sunny/resources/virtual_accounts.rb
111
+ - lib/sunny/resources/webhooks.rb
112
+ - lib/sunny/version.rb
113
+ homepage: https://sunnypay.co.ke
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://sunnypay.co.ke
118
+ source_code_uri: https://github.com/SUNNYPAY-LIMITED/Sunny-Ruby-SDK
119
+ changelog_uri: https://github.com/SUNNYPAY-LIMITED/Sunny-Ruby-SDK/blob/main/CHANGELOG.md
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 2.7.0
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.6.7
135
+ specification_version: 4
136
+ summary: Official Ruby SDK for Sunny Payments
137
+ test_files: []