pushpay-ruby 0.2.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: a3050bdf6e88d960e882abd161991931364db856d95928ef699febbf990dcc4e
4
+ data.tar.gz: 5a5b91dd42cbbf22f4b9b53b22088d519559b6cbf2c43f764061cb4930c011f8
5
+ SHA512:
6
+ metadata.gz: ee443d65225547f53bb4e2d44add1d7ecf83a6b11d06d1a2bb516cf638297bf67d496f8d3f73c3d6c52a003fe7026ad6c9a0746f9f0cca41bd3001fdbebd07a7
7
+ data.tar.gz: 59a571448c813a1398ba7de36146b0510e7be5f88ea0936c120591de5bb2b4cb7a766cd85070aa2bf90129de8db9fcef64250b7894e1fc922e8b40f0816a366c
data/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-03-24
9
+
10
+ ### Changed
11
+ - **Breaking:** Rewritten to match the actual PushPay API
12
+ - Authentication now uses the correct PushPay OAuth 2.0 endpoint (`auth.pushpay.com`) with Basic auth
13
+ - All endpoints use `/v1/` prefix and are scoped by merchant/organization key
14
+ - Configuration uses `client_id`/`client_secret` instead of `application_key`/`application_secret`
15
+ - Responses use HAL+JSON format (`application/hal+json`)
16
+ - Token expiration tracking with automatic re-authentication
17
+
18
+ ### Added
19
+ - `sandbox!` configuration method for sandbox environment
20
+ - `scopes` configuration for OAuth scope requests
21
+ - `RecurringPayment` resource (list, find, linked payments)
22
+ - `AnticipatedPayment` resource (create, list)
23
+ - `Organization` resource (find, search, in_scope, campuses, merchant_listings)
24
+ - `Fund` resource (CRUD + status updates)
25
+ - `Settlement` resource (list, find, payments)
26
+ - `Batch` resource (list, find, payments)
27
+ - `Webhook` resource (CRUD)
28
+ - `NotFoundError` for 404 responses
29
+ - `RateLimitError` now includes `retry_after` value
30
+ - `Base` resource class with merchant/organization path helpers
31
+ - All resources default to `PushPay.client` when no client is passed
32
+ - `PATCH` support on the HTTP client
33
+
34
+ ### Removed
35
+ - `Donation` resource (not a real PushPay API resource)
36
+ - `PaymentPlan` resource (replaced by `RecurringPayment`)
37
+ - `Transaction` resource (not a standalone PushPay API resource)
38
+ - `jwt` dependency (not needed)
39
+ - Hardcoded credentials from client
40
+ - Client-side validation on resources (let the API validate)
41
+
42
+ ## [0.1.1] - 2024-12-01
43
+
44
+ ### Added
45
+ - Initial scaffolding release
46
+
47
+ ## [0.1.0] - 2024-11-15
48
+
49
+ ### Added
50
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Eduardo Souza
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # PushPay Ruby
2
+
3
+ Ruby gem for integrating with the [PushPay](https://pushpay.com) payment processing API. Supports payments, recurring payments, anticipated payments, funds, merchants, organizations, settlements, batches, and webhooks.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'pushpay'
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```bash
22
+ gem install pushpay
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ ```ruby
28
+ PushPay.configure do |config|
29
+ config.client_id = ENV['PUSHPAY_CLIENT_ID']
30
+ config.client_secret = ENV['PUSHPAY_CLIENT_SECRET']
31
+ config.merchant_key = ENV['PUSHPAY_MERCHANT_KEY']
32
+ config.organization_key = ENV['PUSHPAY_ORGANIZATION_KEY']
33
+
34
+ # Optional
35
+ config.scopes = %w[read merchant:view_payments]
36
+ config.timeout = 30 # seconds, default
37
+
38
+ # Use sandbox environment
39
+ # config.sandbox!
40
+ end
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ All resources can be initialized without arguments (uses `PushPay.client` by default) or with an explicit client.
46
+
47
+ ### Payments
48
+
49
+ Payments are read-only in the PushPay API.
50
+
51
+ ```ruby
52
+ payments = PushPay::Payment.new
53
+
54
+ # Get a single payment
55
+ payments.find('payment_token')
56
+
57
+ # List merchant payments with filters
58
+ payments.list(status: 'Success', pageSize: 10, from: '2024-01-01')
59
+
60
+ # List payments across an organization
61
+ payments.list_for_organization
62
+ ```
63
+
64
+ ### Recurring Payments
65
+
66
+ ```ruby
67
+ recurring = PushPay::RecurringPayment.new
68
+
69
+ # Get a recurring payment
70
+ recurring.find('recurring_token')
71
+
72
+ # List recurring payments
73
+ recurring.list
74
+
75
+ # Get payments linked to a recurring schedule
76
+ recurring.payments('recurring_token')
77
+
78
+ # List across an organization
79
+ recurring.list_for_organization
80
+ ```
81
+
82
+ ### Anticipated Payments
83
+
84
+ Create payment links that can be sent to payers.
85
+
86
+ ```ruby
87
+ anticipated = PushPay::AnticipatedPayment.new
88
+
89
+ # Create an anticipated payment
90
+ anticipated.create({ amount: '50.00' })
91
+
92
+ # List anticipated payments
93
+ anticipated.list
94
+ ```
95
+
96
+ ### Merchants
97
+
98
+ ```ruby
99
+ merchants = PushPay::Merchant.new
100
+
101
+ # Get a specific merchant
102
+ merchants.find('merchant_key')
103
+
104
+ # Search merchants
105
+ merchants.search(name: 'Church')
106
+
107
+ # List accessible merchants
108
+ merchants.in_scope
109
+
110
+ # Search nearby
111
+ merchants.near(latitude: '37.7749', longitude: '-122.4194', country: 'US')
112
+ ```
113
+
114
+ ### Organizations
115
+
116
+ ```ruby
117
+ orgs = PushPay::Organization.new
118
+
119
+ # Get an organization
120
+ orgs.find('org_key')
121
+
122
+ # List accessible organizations
123
+ orgs.in_scope
124
+
125
+ # List campuses
126
+ orgs.campuses
127
+
128
+ # List merchant listings
129
+ orgs.merchant_listings
130
+ ```
131
+
132
+ ### Funds
133
+
134
+ ```ruby
135
+ funds = PushPay::Fund.new
136
+
137
+ # List funds for a merchant
138
+ funds.list
139
+
140
+ # List funds for an organization
141
+ funds.list_for_organization
142
+
143
+ # Get a specific fund
144
+ funds.find('fund_key')
145
+
146
+ # Create a fund
147
+ funds.create({ name: 'Missions', taxDeductible: true })
148
+
149
+ # Update a fund
150
+ funds.update('fund_key', { name: 'Updated Name' })
151
+
152
+ # Delete a fund
153
+ funds.delete('fund_key')
154
+ ```
155
+
156
+ ### Settlements
157
+
158
+ ```ruby
159
+ settlements = PushPay::Settlement.new
160
+
161
+ # List settlements
162
+ settlements.list
163
+
164
+ # Get a specific settlement
165
+ settlements.find('settlement_key')
166
+
167
+ # Get payments within a settlement
168
+ settlements.payments('settlement_key')
169
+ ```
170
+
171
+ ### Batches
172
+
173
+ ```ruby
174
+ batches = PushPay::Batch.new
175
+
176
+ # List batches
177
+ batches.list
178
+
179
+ # Get a specific batch
180
+ batches.find('batch_key')
181
+
182
+ # Get payments within a batch
183
+ batches.payments('batch_key')
184
+ ```
185
+
186
+ ### Webhooks
187
+
188
+ ```ruby
189
+ webhooks = PushPay::Webhook.new
190
+
191
+ # List webhooks
192
+ webhooks.list
193
+
194
+ # Create a webhook
195
+ webhooks.create({ target: 'https://example.com/webhook', eventTypes: ['payment_created'] })
196
+
197
+ # Update a webhook
198
+ webhooks.update('webhook_token', { target: 'https://example.com/new' })
199
+
200
+ # Delete a webhook
201
+ webhooks.delete('webhook_token')
202
+ ```
203
+
204
+ ### Using a Custom Merchant/Organization Key
205
+
206
+ All merchant/org-scoped methods accept an optional key override:
207
+
208
+ ```ruby
209
+ payments.list(merchant_key: 'other_merchant')
210
+ funds.list_for_organization(organization_key: 'other_org')
211
+ ```
212
+
213
+ ## Error Handling
214
+
215
+ ```ruby
216
+ begin
217
+ payments.list
218
+ rescue PushPay::ConfigurationError => e
219
+ # Missing API credentials
220
+ rescue PushPay::AuthenticationError => e
221
+ # OAuth authentication failed
222
+ rescue PushPay::ValidationError => e
223
+ puts e.errors # Detailed validation failures
224
+ rescue PushPay::NotFoundError => e
225
+ # 404 - Resource not found
226
+ rescue PushPay::RateLimitError => e
227
+ puts e.retry_after # Seconds to wait
228
+ rescue PushPay::APIError => e
229
+ puts e.status_code
230
+ puts e.response_body
231
+ end
232
+ ```
233
+
234
+ ## Development
235
+
236
+ ```bash
237
+ bundle install
238
+ bundle exec rspec
239
+ bundle exec rubocop
240
+ ```
241
+
242
+ ## License
243
+
244
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class AnticipatedPayment < Base
5
+ # Create a new anticipated payment
6
+ def create(params, merchant_key: nil)
7
+ client.post("#{merchant_path(merchant_key)}/anticipatedpayments", params)
8
+ end
9
+
10
+ # List anticipated payments for a merchant
11
+ def list(merchant_key: nil, **params)
12
+ client.get("#{merchant_path(merchant_key)}/anticipatedpayments", params)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Base
5
+ attr_reader :client
6
+
7
+ def initialize(client = nil)
8
+ @client = client || PushPay.client
9
+ end
10
+
11
+ private
12
+
13
+ def merchant_path(merchant_key = nil)
14
+ key = merchant_key || client.configuration.merchant_key
15
+ raise ConfigurationError, ["merchant_key"] unless key
16
+ "/v1/merchant/#{key}"
17
+ end
18
+
19
+ def organization_path(organization_key = nil)
20
+ key = organization_key || client.configuration.organization_key
21
+ raise ConfigurationError, ["organization_key"] unless key
22
+ "/v1/organization/#{key}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Batch < Base
5
+ # Get a specific batch
6
+ def find(batch_key, merchant_key: nil)
7
+ client.get("#{merchant_path(merchant_key)}/batch/#{batch_key}")
8
+ end
9
+
10
+ # List batches for a merchant
11
+ def list(merchant_key: nil, **params)
12
+ client.get("#{merchant_path(merchant_key)}/batches", params)
13
+ end
14
+
15
+ # List batches for an organization
16
+ def list_for_organization(organization_key: nil, **params)
17
+ client.get("#{organization_path(organization_key)}/batches", params)
18
+ end
19
+
20
+ # Get payments within a batch
21
+ def payments(batch_key, merchant_key: nil, **params)
22
+ client.get("#{merchant_path(merchant_key)}/batch/#{batch_key}/payments", params)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module PushPay
6
+ class Client
7
+ include HTTParty
8
+
9
+ attr_reader :configuration, :access_token, :token_expires_at
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ validate_configuration!
14
+ @access_token = nil
15
+ @token_expires_at = nil
16
+ end
17
+
18
+ def authenticate!
19
+ credentials = Base64.strict_encode64("#{configuration.client_id}:#{configuration.client_secret}")
20
+
21
+ scopes = configuration.scopes.empty? ? ["read"] : configuration.scopes
22
+ body = { grant_type: 'client_credentials', scope: scopes.join(' ') }
23
+
24
+ response = self.class.post(
25
+ configuration.auth_url,
26
+ body: body,
27
+ headers: {
28
+ 'Authorization' => "Basic #{credentials}",
29
+ 'Content-Type' => 'application/x-www-form-urlencoded'
30
+ },
31
+ timeout: configuration.timeout
32
+ )
33
+
34
+ unless response.success?
35
+ raise AuthenticationError, "Failed to authenticate: #{response.code} - #{response.body}"
36
+ end
37
+
38
+ @access_token = response['access_token']
39
+ @token_expires_at = Time.now + (response['expires_in'] || 3600)
40
+ @access_token
41
+ end
42
+
43
+ def get(path, params = {})
44
+ request(:get, path, query: params)
45
+ end
46
+
47
+ def post(path, data = {})
48
+ request(:post, path, body: data.to_json)
49
+ end
50
+
51
+ def put(path, data = {})
52
+ request(:put, path, body: data.to_json)
53
+ end
54
+
55
+ def patch(path, data = {})
56
+ request(:patch, path, body: data.to_json)
57
+ end
58
+
59
+ def delete(path)
60
+ request(:delete, path)
61
+ end
62
+
63
+ private
64
+
65
+ def validate_configuration!
66
+ missing = configuration.missing_credentials
67
+ raise ConfigurationError, missing unless missing.empty?
68
+ end
69
+
70
+ def ensure_authenticated!
71
+ authenticate! if @access_token.nil? || token_expired?
72
+ end
73
+
74
+ def token_expired?
75
+ @token_expires_at && Time.now >= @token_expires_at
76
+ end
77
+
78
+ def request(method, path, options = {})
79
+ ensure_authenticated!
80
+
81
+ url = "#{configuration.base_url}#{path}"
82
+ request_options = {
83
+ headers: auth_headers,
84
+ timeout: configuration.timeout
85
+ }
86
+
87
+ if options[:query]
88
+ request_options[:query] = options[:query]
89
+ end
90
+
91
+ if options[:body]
92
+ request_options[:body] = options[:body]
93
+ request_options[:headers] = request_options[:headers].merge('Content-Type' => 'application/json')
94
+ end
95
+
96
+ response = self.class.send(method, url, request_options)
97
+ handle_response(response)
98
+ end
99
+
100
+ def auth_headers
101
+ {
102
+ 'Authorization' => "Bearer #{@access_token}",
103
+ 'Accept' => 'application/hal+json'
104
+ }
105
+ end
106
+
107
+ def handle_response(response)
108
+ case response.code
109
+ when 200, 201, 202, 204
110
+ response.parsed_response
111
+ when 401
112
+ @access_token = nil
113
+ @token_expires_at = nil
114
+ raise AuthenticationError, "Authentication failed: #{response.body}"
115
+ when 404
116
+ raise NotFoundError.new("Resource not found", response.code, response.body)
117
+ when 429
118
+ retry_after = response.headers['retry-after']
119
+ raise RateLimitError.new("Rate limit exceeded", response.code, response.body, retry_after)
120
+ when 400, 422
121
+ parsed = response.parsed_response || {}
122
+ errors = parsed['validationFailures'] || {}
123
+ raise ValidationError.new(
124
+ parsed['message'] || 'Validation failed',
125
+ response.code,
126
+ response.body,
127
+ errors
128
+ )
129
+ when 500..599
130
+ raise APIError.new("Server error", response.code, response.body)
131
+ else
132
+ raise APIError.new("Unexpected response", response.code, response.body)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Configuration
5
+ attr_accessor :client_id, :client_secret, :merchant_key, :organization_key,
6
+ :base_url, :auth_url, :timeout, :scopes
7
+
8
+ def initialize
9
+ @base_url = "https://api.pushpay.io"
10
+ @auth_url = "https://auth.pushpay.com/pushpay/oauth/token"
11
+ @timeout = 30
12
+ @scopes = ["read"]
13
+ end
14
+
15
+ def sandbox!
16
+ @base_url = "https://sandbox-api.pushpay.io"
17
+ @auth_url = "https://auth.pushpay.com/pushpay-sandbox/oauth/token"
18
+ self
19
+ end
20
+
21
+ def valid?
22
+ !client_id.nil? && !client_id.empty? &&
23
+ !client_secret.nil? && !client_secret.empty?
24
+ end
25
+
26
+ def missing_credentials
27
+ missing = []
28
+ missing << "client_id" if client_id.nil? || client_id.to_s.empty?
29
+ missing << "client_secret" if client_secret.nil? || client_secret.to_s.empty?
30
+ missing
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error
7
+ def initialize(missing_credentials)
8
+ super("Missing required credentials: #{missing_credentials.join(', ')}")
9
+ end
10
+ end
11
+
12
+ class AuthenticationError < Error
13
+ def initialize(message = "Authentication failed")
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ class APIError < Error
19
+ attr_reader :status_code, :response_body
20
+
21
+ def initialize(message, status_code = nil, response_body = nil)
22
+ super(message)
23
+ @status_code = status_code
24
+ @response_body = response_body
25
+ end
26
+ end
27
+
28
+ class ValidationError < APIError
29
+ attr_reader :errors
30
+
31
+ def initialize(message, status_code = nil, response_body = nil, errors = {})
32
+ super(message, status_code, response_body)
33
+ @errors = errors
34
+ end
35
+ end
36
+
37
+ class NotFoundError < APIError
38
+ def initialize(message = "Resource not found", status_code = 404, response_body = nil)
39
+ super(message, status_code, response_body)
40
+ end
41
+ end
42
+
43
+ class RateLimitError < APIError
44
+ attr_reader :retry_after
45
+
46
+ def initialize(message = "Rate limit exceeded", status_code = 429, response_body = nil, retry_after = nil)
47
+ super(message, status_code, response_body)
48
+ @retry_after = retry_after
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Fund < Base
5
+ # Get a specific fund
6
+ def find(fund_key, organization_key: nil)
7
+ client.get("#{organization_path(organization_key)}/fund/#{fund_key}")
8
+ end
9
+
10
+ # List funds for a merchant
11
+ def list(merchant_key: nil, **params)
12
+ client.get("#{merchant_path(merchant_key)}/funds", params)
13
+ end
14
+
15
+ # List funds for an organization
16
+ def list_for_organization(organization_key: nil, **params)
17
+ client.get("#{organization_path(organization_key)}/funds", params)
18
+ end
19
+
20
+ # Create a fund
21
+ def create(params, organization_key: nil)
22
+ client.post("#{organization_path(organization_key)}/funds", params)
23
+ end
24
+
25
+ # Update a fund
26
+ def update(fund_key, params, organization_key: nil)
27
+ client.put("#{organization_path(organization_key)}/fund/#{fund_key}", params)
28
+ end
29
+
30
+ # Change fund status (open/close)
31
+ def update_status(fund_key, params, organization_key: nil)
32
+ client.patch("#{organization_path(organization_key)}/fund/#{fund_key}", params)
33
+ end
34
+
35
+ # Delete a fund
36
+ def delete(fund_key, organization_key: nil)
37
+ client.delete("#{organization_path(organization_key)}/fund/#{fund_key}")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PushPay
4
+ class Merchant < Base
5
+ # Get a specific merchant
6
+ def find(merchant_key)
7
+ client.get("/v1/merchant/#{merchant_key}")
8
+ end
9
+
10
+ # Search merchants by name or handle
11
+ def search(**params)
12
+ client.get("/v1/merchants", params)
13
+ end
14
+
15
+ # List merchants accessible to the current application
16
+ def in_scope(**params)
17
+ client.get("/v1/merchants/in-scope", params)
18
+ end
19
+
20
+ # Search for merchants near a location
21
+ def near(latitude:, longitude:, country: nil, **params)
22
+ query = { latitude: latitude, longitude: longitude }
23
+ query[:country] = country if country
24
+ query.merge!(params)
25
+ client.get("/v1/merchants/near", query)
26
+ end
27
+ end
28
+ end