pay-square 0.1.0.rc1

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: 94a7592d85a226f50c70b335728f80342fb8238da4625b0143e3e8ee5fd47ec1
4
+ data.tar.gz: 6d1ee0770171be37469056cb15a23ed5a6c7a699fa864725ff30be5a5ff670b0
5
+ SHA512:
6
+ metadata.gz: 35c828140e98a9469cf68afa423cfa0e00294ddb95c0c6ac8b92e79ec1f9faff5fe4d3d4f0a1e78f20ee669125d4125d65776075e7803efa4281f374c57928e4
7
+ data.tar.gz: e6f863aa1776fe3168a8550927ec83a25ab2188063f5a36389c830d0c3f53f2b03a0975b51fadf386b8467383dff0a866a677db2571e6b038ae268fdc4b2a293
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ * Adopted Pay's `.github` conventions: a `Standardrb` lint job in CI, a reusable `ci.yml`
6
+ (`workflow_call`) that the publish workflow runs before releasing, per-job timeouts, and
7
+ `actions/checkout@v6`. Renamed `release.yml` to `publish_gem.yml`.
8
+ * Added `.standard.yml` (ignoring the generated dummy app), `dependabot.yml` (bundler + GitHub
9
+ Actions, monthly), a pull request template, and bug/feature issue templates.
10
+
11
+ * Added CI (`.github/workflows/ci.yml`): runs the test suite on every pull request and push to
12
+ `main`, across Ruby 3.1–3.4 and Pay 11 + 12, behind a single `All tests passed` check.
13
+ * The release workflow now runs the tests first and only publishes to RubyGems if they pass.
14
+ * `Gemfile` honors `PAY_VERSION` so CI can pin the Pay major under test.
15
+
16
+ * Added trusted-publishing release workflow (`.github/workflows/release.yml`): a `v*` tag push
17
+ publishes to RubyGems via OpenID Connect, no stored API key. See `RELEASING.md`.
18
+ * Removed the generated `gem-push.yml` starter workflow in favor of `release.yml` (it published on
19
+ every push to main, pinned Ruby 2.6, and also pushed to GitHub Packages).
20
+ * README: added gem version / Ruby / license badges and a Links section; vendored the RubyGems
21
+ logos under `assets/rubygems/`.
22
+ * README reorganized to follow Pay's conventions: Pay logo + tagline, badges on one line, a blockquote
23
+ for the upstream endorsement, and a `📚 Docs` section linking to dedicated pages under `docs/`
24
+ (installation, configuration, usage, webhooks).
25
+ * gemspec now sets `rubygems_mfa_required`, and drops the duplicate `homepage_uri` metadata.
26
+
27
+ ## 0.1.0.rc1 - 2026-10-21
28
+
29
+ First pre-release, cut for verification in a real consuming app before `0.1.0` is tagged
30
+ (see [RELEASING.md](RELEASING.md) for our pre-release policy).
31
+
32
+ * Compatible with Pay 11.x and 12.x (verified against Pay 11.7.2 and 12.3.0).
33
+ * The Square SDK (`square.rb`) is an app-provided dependency, matching Pay's convention for its
34
+ built-in processors. Add `gem "square.rb", "~> 44"` to your app's Gemfile; `pay-square` no longer
35
+ declares it and no longer `require`s it (the processor gates on `defined?(::Square)`).
36
+ * Added `RELEASING.md` (versioning and tag conventions) and `CONTRIBUTING.md`; documented release
37
+ and pre-release pinning in the README.
38
+ * Fixed test suite boot order so the dummy app's environment loads before the gem.
39
+ * Initial release: Square payment processor for Pay.
40
+ * Charge on a merchant's own connected Square account (per-merchant OAuth) via a
41
+ configurable `Pay::Square.access_token_provider`; a `Square::Client` is built per
42
+ operation and retries once with a refreshed token on a 401.
43
+ * Card-on-file (Cards API), charges (`CreatePayment`) with optional `app_fee_money`,
44
+ and refunds.
45
+ * Pay-mounted `/pay/webhooks/square` endpoint with HMAC signature verification; handlers
46
+ re-fetch authoritative state (Square delivery is unordered).
47
+ * Adds a `square_account` column to scope customers per merchant, with cross-merchant
48
+ isolation in `set/add_payment_processor`.
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Willard Moore
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,74 @@
1
+ <p align="center">
2
+ <a href="https://github.com/pay-rails/pay"><img src="https://raw.githubusercontent.com/pay-rails/pay/main/docs/images/logo.svg" height="50px" alt="Pay"></a>
3
+ </p>
4
+
5
+ # 💳 Pay::Square — Square for the Pay gem
6
+
7
+ [![Gem Version](https://img.shields.io/gem/v/pay-square?logo=rubygems&logoColor=white)](https://rubygems.org/gems/pay-square) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D?logo=ruby&logoColor=white)](pay-square.gemspec) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
8
+
9
+ A **[Square](https://squareup.com/)** payment processor for **[Pay — the payments engine for Ruby on Rails](https://github.com/pay-rails/pay)**.
10
+
11
+ ## 🏦 Extending Pay — and why Square is its own gem
12
+
13
+ [Pay](https://github.com/pay-rails/pay) already speaks Stripe, Braintree, Paddle, and Lemon Squeezy.
14
+ `pay-square` adds **Square** from the outside — its own gem, no fork of Pay — the same self-registering
15
+ plugin pattern as [`pay-asaas`](https://github.com/PedroAugustoRamalhoDuarte/pay-asaas).
16
+
17
+ Pay's maintainer, Chris Oliver, both welcomed this and is taking the same direction for Pay's own
18
+ processors:
19
+
20
+ > I'm planning to extract Braintree and Paddle from the main gem. It doesn't bloat anything having them
21
+ > in the gem since they're lazy loaded, but easier to maintain. […] long-term we can add it to the pay
22
+ > org if you would like to keep maintaining it.
23
+ >
24
+ > — [Chris Oliver, pay-rails/pay#1251](https://github.com/pay-rails/pay/pull/1251#issuecomment-5545503069)
25
+
26
+ ### Not just a processor — a marketplace integration
27
+
28
+ Braintree and Paddle are being split out for maintainability alone. Square is a different case: here it
29
+ is used for **[marketplace payments](https://github.com/pay-rails/pay/tree/main/docs/marketplaces)** —
30
+ Pay's term for charging on a connected merchant's **own account** and taking an **application fee** —
31
+ not merely to charge your own customers.
32
+
33
+ Square also issues a **distinct OAuth access token per merchant** (unlike Stripe Connect's single
34
+ platform key plus a `stripe_account` header). So `pay-square` charges on a merchant's **own connected
35
+ Square account** (the merchant, not you, is the merchant of record), building a `Square::Client` per
36
+ operation from a token you supply via `Pay::Square.access_token_provider`, and isolates customers per
37
+ merchant with a `square_account` column. That on-behalf-of, per-merchant model — not plain processing —
38
+ is a core reason Square belongs in its own gem.
39
+
40
+ Scope: card payments, card-on-file, refunds, and payment/refund/card/customer webhooks. Square OAuth,
41
+ token refresh, payouts/reconciliation, catalog, and the checkout UI stay in your app.
42
+
43
+ ## 📋 Requirements
44
+
45
+ - Ruby >= 3.1.0
46
+ - [Pay](https://github.com/pay-rails/pay) 11.x or 12.x (verified against 11.7.2 and 12.3.0)
47
+ - [square.rb](https://github.com/square/square-ruby-sdk) ~> 44
48
+
49
+ ## 📚 Docs
50
+
51
+ - [Installation](docs/installation.md) — Gemfile, pre-releases, and the migration
52
+ - [Configuration](docs/configuration.md) — the per-merchant token resolver and credentials
53
+ - [Usage](docs/usage.md) — charges, card-on-file, refunds, and per-merchant isolation
54
+ - [Webhooks](docs/webhooks.md) — endpoint, signature verification, and handled events
55
+ - [Versioning & releasing](RELEASING.md) — SemVer, tags, pre-releases, and publishing
56
+ - [Contributing](CONTRIBUTING.md) — dev setup, running the tests, and opening issues
57
+
58
+ ## 🙏 Contributing
59
+
60
+ Bug reports, feature ideas, and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) to
61
+ get started.
62
+
63
+ ## 🔗 Links
64
+
65
+ [<img src="assets/rubygems/rubygems_logo_red.png" alt="RubyGems" height="24" align="left">](https://rubygems.org/gems/pay-square)
66
+
67
+ [pay-square on RubyGems](https://rubygems.org/gems/pay-square) · [Changelog](CHANGELOG.md)
68
+
69
+ The RubyGems and Pay logos are trademarks of their respective projects, used here to link to those
70
+ projects, not to imply endorsement.
71
+
72
+ ## 📝 License
73
+
74
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
5
+ load "rails/tasks/engine.rake"
6
+
7
+ require "rake/testtask"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.pattern = "test/**/*_test.rb"
12
+ t.verbose = false
13
+ end
14
+
15
+ task default: :test
@@ -0,0 +1,48 @@
1
+ module Pay
2
+ module Webhooks
3
+ class SquareController < ActionController::API
4
+ def create
5
+ if valid_signature?(request.headers["x-square-hmacsha256-signature"])
6
+ queue_event(verify_params.as_json)
7
+ head :ok
8
+ else
9
+ head :bad_request
10
+ end
11
+ rescue Pay::Square::Error
12
+ head :bad_request
13
+ end
14
+
15
+ private
16
+
17
+ def queue_event(event)
18
+ return unless Pay::Webhooks.delegator.listening?("square.#{params[:type]}")
19
+
20
+ record = Pay::Webhook.create!(processor: :square, event_type: params[:type], event: event)
21
+ Pay::Webhooks::ProcessJob.perform_later(record)
22
+ end
23
+
24
+ # Square signs the HMAC-SHA256 over (notification_url + raw body), base64-encoded.
25
+ # notification_url must match the URL registered with Square; behind a proxy
26
+ # request.original_url may differ, so it is configurable.
27
+ def valid_signature?(signature)
28
+ return false if signature.blank?
29
+
30
+ key = Pay::Square.signing_secret
31
+ return false if key.blank?
32
+
33
+ data = notification_url + request.raw_post
34
+ digest = OpenSSL::Digest.new("sha256")
35
+ hmac = Base64.strict_encode64(OpenSSL::HMAC.digest(digest, key, data))
36
+ ActiveSupport::SecurityUtils.secure_compare(hmac, signature)
37
+ end
38
+
39
+ def notification_url
40
+ Pay::Square.webhook_notification_url.presence || request.original_url
41
+ end
42
+
43
+ def verify_params
44
+ params.except(:action, :controller).permit!
45
+ end
46
+ end
47
+ end
48
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ Pay::Square::Engine.routes.draw do
2
+ post "webhooks/square", to: "pay/webhooks/square#create" if Pay::Square.enabled?
3
+ end
@@ -0,0 +1,10 @@
1
+ class AddSquareAccountToPayModels < ActiveRecord::Migration[7.0]
2
+ # Scopes a Pay::Customer / PaymentMethod / Charge to a Square merchant (mirrors
3
+ # stripe_account). Install into your app with:
4
+ # bin/rails pay_square:install:migrations && bin/rails db:migrate
5
+ def change
6
+ add_column :pay_customers, :square_account, :string
7
+ add_column :pay_payment_methods, :square_account, :string
8
+ add_column :pay_charges, :square_account, :string
9
+ end
10
+ end
@@ -0,0 +1,39 @@
1
+ module Pay
2
+ module Square
3
+ # Pay's core set/add_payment_processor dedup key is hardcoded and does not know about
4
+ # square_account. We prepend to include it for the :square processor so one owner
5
+ # transacting with two Square merchants gets two isolated Pay::Customers (the same
6
+ # guarantee stripe_account provides via #1198). Everything else delegates to super.
7
+ module Attributes
8
+ def set_payment_processor(processor_name, square_account: nil, **options)
9
+ return super(processor_name, **options) unless processor_name.to_sym == :square
10
+
11
+ with_lock do
12
+ pay_customers.update_all(default: false)
13
+ pay_customer = pay_customers.active.where(
14
+ Pay::Customer.inheritance_column => "Pay::Square::Customer",
15
+ :processor => "square",
16
+ :square_account => square_account
17
+ ).first_or_initialize
18
+ pay_customer.update!(options.merge(default: true))
19
+ end
20
+
21
+ reload_payment_processor
22
+ end
23
+
24
+ def add_payment_processor(processor_name, square_account: nil, **options)
25
+ return super(processor_name, **options) unless processor_name.to_sym == :square
26
+
27
+ pay_customer = pay_customers.active.where(
28
+ Pay::Customer.inheritance_column => "Pay::Square::Customer",
29
+ :processor => "square",
30
+ :square_account => square_account
31
+ ).first_or_initialize
32
+ pay_customer.update!(options)
33
+ pay_customer
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ Pay::Attributes::CustomerExtension.prepend(Pay::Square::Attributes)
@@ -0,0 +1,78 @@
1
+ module Pay
2
+ module Square
3
+ class Charge < Pay::Charge
4
+ store_accessor :data, :square_order_id
5
+ store_accessor :data, :square_status
6
+ store_accessor :data, :square_merchant_id
7
+
8
+ # Re-fetches authoritative payment state (webhook delivery is unordered), unless a
9
+ # freshly created payment is passed in.
10
+ def self.sync(payment_id, object: nil, pay_customer: nil, try: 0, retries: 1)
11
+ object ||= Pay::Square.with_client(pay_customer) { |client| client.payments.get(payment_id: payment_id) }.payment
12
+ return if object.nil?
13
+
14
+ pay_customer ||= Pay::Customer.find_by(processor: :square, processor_id: object.customer_id)
15
+ if pay_customer.nil?
16
+ Rails.logger.debug "Pay::Customer for Square payment #{payment_id} (customer #{object.customer_id}) is not in the database"
17
+ return
18
+ end
19
+
20
+ card = object.card_details&.card
21
+ attrs = {
22
+ amount: object.amount_money&.amount,
23
+ currency: object.amount_money&.currency&.downcase,
24
+ application_fee_amount: object.app_fee_money&.amount,
25
+ amount_refunded: object.refunded_money&.amount || 0,
26
+ payment_method_type: "card",
27
+ brand: card&.card_brand,
28
+ last4: card&.last_4.to_s,
29
+ exp_month: card&.exp_month&.to_s,
30
+ exp_year: card&.exp_year&.to_s,
31
+ square_account: pay_customer.square_account,
32
+ square_order_id: object.order_id,
33
+ square_status: object.status,
34
+ square_merchant_id: (object.respond_to?(:merchant_id) ? object.merchant_id : nil)
35
+ }
36
+ attrs[:created_at] = Time.zone.parse(object.created_at.to_s) if object.respond_to?(:created_at) && object.created_at.present?
37
+
38
+ if (pay_charge = find_by(customer: pay_customer, processor_id: object.id))
39
+ pay_charge.with_lock { pay_charge.update!(attrs) }
40
+ pay_charge
41
+ else
42
+ create!(attrs.merge(customer: pay_customer, processor_id: object.id))
43
+ end
44
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
45
+ if try >= retries
46
+ raise
47
+ else
48
+ sleep 0.15**(try + 1)
49
+ retry_args = {object: object, pay_customer: pay_customer, try: try + 1, retries: retries}
50
+ sync(payment_id, **retry_args)
51
+ end
52
+ end
53
+
54
+ def api_record
55
+ Pay::Square.with_client(customer) { |client| client.payments.get(payment_id: processor_id) }.payment
56
+ end
57
+
58
+ # Square refunds are asynchronous (PENDING -> COMPLETED, can fail), so re-sync
59
+ # amount_refunded from the payment rather than optimistically incrementing.
60
+ def refund!(amount_to_refund = nil, **options)
61
+ amount_to_refund ||= amount
62
+ idempotency_key = options.delete(:idempotency_key) || SecureRandom.uuid
63
+
64
+ Pay::Square.with_client(customer) do |client|
65
+ client.refunds.refund_payment(
66
+ idempotency_key: idempotency_key,
67
+ payment_id: processor_id,
68
+ amount_money: {amount: amount_to_refund, currency: (currency || "usd").to_s.upcase}
69
+ )
70
+ end
71
+
72
+ self.class.sync(processor_id, pay_customer: customer)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ ActiveSupport.run_load_hooks :pay_square_charge, Pay::Square::Charge
@@ -0,0 +1,79 @@
1
+ module Pay
2
+ module Square
3
+ class Customer < Pay::Customer
4
+ has_many :charges, dependent: :destroy, class_name: "Pay::Square::Charge"
5
+ has_many :payment_methods, dependent: :destroy, class_name: "Pay::Square::PaymentMethod"
6
+ has_one :default_payment_method, -> { where(default: true) }, class_name: "Pay::Square::PaymentMethod"
7
+
8
+ # Override this in your Pay::Square::Customer (or via ActiveSupport.on_load
9
+ # :pay_square_customer) to send more fields to Square when creating the customer.
10
+ def api_record_attributes
11
+ {email_address: email, given_name: customer_name}.compact
12
+ end
13
+
14
+ def api_record
15
+ if processor_id?
16
+ Pay::Square.with_client(self) { |client| client.customers.get(customer_id: processor_id) }.customer
17
+ else
18
+ result = Pay::Square.with_client(self) { |client| client.customers.create(**api_record_attributes) }.customer
19
+ update!(processor_id: result.id)
20
+ result
21
+ end
22
+ end
23
+
24
+ # Pass a stable idempotency_key (e.g. keyed on the order) to make app-level resubmits
25
+ # dedupe; otherwise a generated key covers only this call and its 401 retry.
26
+ def charge(amount, options = {})
27
+ options = options.dup
28
+ idempotency_key = options.delete(:idempotency_key) || SecureRandom.uuid
29
+ currency = (options.delete(:currency) || "usd").to_s.upcase
30
+ source_id = options.delete(:source_id) || options.delete(:payment_method_id) || default_payment_method&.processor_id
31
+ raise Pay::Square::Error, "No source_id or default payment method for Square charge" if source_id.blank?
32
+
33
+ params = {
34
+ idempotency_key: idempotency_key,
35
+ source_id: source_id,
36
+ amount_money: {amount: amount, currency: currency},
37
+ customer_id: processor_id || api_record.id
38
+ }
39
+ if (app_fee = options.delete(:application_fee_amount) || options.delete(:app_fee_amount))
40
+ params[:app_fee_money] = {amount: app_fee, currency: currency}
41
+ end
42
+ params.merge!(options)
43
+
44
+ payment = Pay::Square.with_client(self) { |client| client.payments.create(**params) }.payment
45
+ Pay::Square::Charge.sync(payment.id, object: payment, pay_customer: self)
46
+ end
47
+
48
+ # source_id is a single-use nonce minted client-side by Square's Web Payments SDK.
49
+ def add_payment_method(source_id, default: false)
50
+ api_record unless processor_id?
51
+ idempotency_key = SecureRandom.uuid
52
+
53
+ card = Pay::Square.with_client(self) do |client|
54
+ client.cards.create(idempotency_key: idempotency_key, source_id: source_id, card: {customer_id: processor_id})
55
+ end.card
56
+
57
+ save_payment_method(card, default: default)
58
+ end
59
+
60
+ def update_payment_method(source_id)
61
+ add_payment_method(source_id, default: true)
62
+ end
63
+
64
+ def save_payment_method(card, default:)
65
+ pay_payment_method = payment_methods.where(processor_id: card.id).first_or_initialize
66
+
67
+ attributes = Pay::Square::PaymentMethod.extract_attributes(card).merge(default: default, square_account: square_account)
68
+
69
+ payment_methods.where.not(id: pay_payment_method.id).update_all(default: false) if default
70
+ pay_payment_method.update!(attributes)
71
+
72
+ reload_default_payment_method
73
+ pay_payment_method
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ ActiveSupport.run_load_hooks :pay_square_customer, Pay::Square::Customer
@@ -0,0 +1,30 @@
1
+ require "rails/engine"
2
+
3
+ module Pay
4
+ module Square
5
+ class Engine < ::Rails::Engine
6
+ engine_name "pay_square"
7
+
8
+ initializer "pay_square.processors" do |app|
9
+ if Pay.automount_routes
10
+ app.routes.append do
11
+ mount Pay::Square::Engine, at: Pay.routes_path
12
+ end
13
+ end
14
+
15
+ ActiveSupport.on_load(:active_record) do
16
+ include Pay::Attributes
17
+ end
18
+ end
19
+
20
+ # Subscribe webhooks before app initializers, matching Pay's own ordering.
21
+ config.before_initialize do
22
+ Pay::Square.configure_webhooks if Pay::Square.enabled?
23
+ end
24
+
25
+ config.to_prepare do
26
+ Pay::Square.setup if Pay::Square.enabled?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ module Pay
2
+ module Square
3
+ class PaymentMethod < Pay::PaymentMethod
4
+ # Pass `object` (the Square card) when you have it; otherwise it is re-fetched.
5
+ def self.sync(card_id, object: nil, pay_customer: nil, try: 0, retries: 1)
6
+ object ||= Pay::Square.with_client(pay_customer) { |client| client.cards.get(card_id: card_id) }.card
7
+ return if object.nil?
8
+
9
+ pay_customer ||= Pay::Customer.find_by(processor: :square, processor_id: object.customer_id)
10
+ return if pay_customer.nil?
11
+
12
+ pay_payment_method = pay_customer.payment_methods.where(processor_id: object.id).first_or_initialize
13
+ pay_payment_method.update!(extract_attributes(object).merge(square_account: pay_customer.square_account))
14
+ pay_payment_method
15
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
16
+ if try >= retries
17
+ raise
18
+ else
19
+ sleep 0.15**(try + 1)
20
+ sync(card_id, object: object, pay_customer: pay_customer, try: try + 1, retries: retries)
21
+ end
22
+ end
23
+
24
+ def self.extract_attributes(card)
25
+ {
26
+ payment_method_type: "card",
27
+ brand: card.card_brand,
28
+ last4: card.last_4.to_s,
29
+ exp_month: card.exp_month.to_s,
30
+ exp_year: card.exp_year.to_s
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ ActiveSupport.run_load_hooks :pay_square_payment_method, Pay::Square::PaymentMethod
@@ -0,0 +1,5 @@
1
+ module Pay
2
+ module Square
3
+ VERSION = "0.1.0.rc1"
4
+ end
5
+ end
@@ -0,0 +1,22 @@
1
+ module Pay
2
+ module Square
3
+ module Webhooks
4
+ class Card
5
+ def call(event)
6
+ event = Pay::Square.construct_from_webhook_event(event)
7
+ object = event.data&.object&.card
8
+ return unless object&.id
9
+
10
+ pay_customer = object.customer_id.present? ? Pay::Customer.find_by(processor: :square, processor_id: object.customer_id) : nil
11
+ return unless pay_customer
12
+
13
+ if event.type.to_s == "card.disabled"
14
+ Pay::PaymentMethod.find_by_processor_and_id(:square, object.id)&.destroy
15
+ else
16
+ Pay::Square::PaymentMethod.sync(object.id, pay_customer: pay_customer)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ module Pay
2
+ module Square
3
+ module Webhooks
4
+ class Customer
5
+ def call(event)
6
+ event = Pay::Square.construct_from_webhook_event(event)
7
+ object = event.data&.object&.customer
8
+ return unless object&.id
9
+
10
+ pay_customer = Pay::Customer.find_by(processor: :square, processor_id: object.id)
11
+ return unless pay_customer
12
+
13
+ if event.type.to_s == "customer.deleted"
14
+ pay_customer.update(deleted_at: Time.current) if pay_customer.respond_to?(:deleted_at)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ module Pay
2
+ module Square
3
+ module Webhooks
4
+ class Payment
5
+ def call(event)
6
+ event = Pay::Square.construct_from_webhook_event(event)
7
+ object = event.data&.object&.payment
8
+ return unless object&.id
9
+
10
+ pay_customer = object.customer_id.present? ? Pay::Customer.find_by(processor: :square, processor_id: object.customer_id) : nil
11
+ return unless pay_customer
12
+
13
+ pay_charge = Pay::Square::Charge.sync(object.id, pay_customer: pay_customer)
14
+
15
+ if pay_charge && Pay.send_email?(:receipt, pay_charge)
16
+ Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).receipt.deliver_later
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ module Pay
2
+ module Square
3
+ module Webhooks
4
+ class Refund
5
+ def call(event)
6
+ event = Pay::Square.construct_from_webhook_event(event)
7
+ object = event.data&.object&.refund
8
+ return unless object&.payment_id
9
+
10
+ pay_charge = Pay::Charge.find_by(processor_id: object.payment_id)
11
+ pay_customer = pay_charge&.customer || ((object.respond_to?(:customer_id) && object.customer_id.present?) ? Pay::Customer.find_by(processor: :square, processor_id: object.customer_id) : nil)
12
+ return unless pay_customer
13
+
14
+ pay_charge = Pay::Square::Charge.sync(object.payment_id, pay_customer: pay_customer)
15
+
16
+ if pay_charge && Pay.send_email?(:refund, pay_charge)
17
+ Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).refund.deliver_later
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/pay/square.rb ADDED
@@ -0,0 +1,129 @@
1
+ require "pay"
2
+ # square.rb is app-provided (see gemspec); intentionally not required here.
3
+ require "pay/square/version"
4
+ require "pay/square/engine"
5
+ require "pay/square/attributes"
6
+
7
+ module Pay
8
+ module Square
9
+ # Unlike Stripe Connect (one platform key + a stripe_account header), Square issues a
10
+ # distinct OAuth access token per merchant, so the processor builds a Square::Client
11
+ # per operation from a token supplied by access_token_provider.
12
+ class Error < Pay::Error
13
+ end
14
+
15
+ autoload :Customer, "pay/square/customer"
16
+ autoload :Charge, "pay/square/charge"
17
+ autoload :PaymentMethod, "pay/square/payment_method"
18
+
19
+ module Webhooks
20
+ autoload :Payment, "pay/square/webhooks/payment"
21
+ autoload :Refund, "pay/square/webhooks/refund"
22
+ autoload :Card, "pay/square/webhooks/card"
23
+ autoload :Customer, "pay/square/webhooks/customer"
24
+ end
25
+
26
+ extend Pay::Env
27
+
28
+ REQUIRED_VERSION = "~> 44"
29
+
30
+ # A callable accepting (pay_customer, force_refresh = false) that returns a fresh
31
+ # per-merchant OAuth access token. force_refresh is set true on the retry after a 401.
32
+ mattr_accessor :access_token_provider, default: nil
33
+
34
+ def self.enabled?
35
+ return false unless defined?(::Square)
36
+
37
+ # square.rb does not autoload Square::VERSION on `require "square"`.
38
+ current = ::Gem.loaded_specs["square.rb"]&.version&.to_s
39
+ return true if current.nil?
40
+ Pay::Engine.version_matches?(required: REQUIRED_VERSION, current: current) || (raise "[Pay::Square] square.rb gem must be version #{REQUIRED_VERSION}")
41
+ end
42
+
43
+ # No global API key: Square authenticates per-merchant via access_token_provider.
44
+ def self.setup
45
+ end
46
+
47
+ def self.environment
48
+ find_value_by_name(:square, :environment).presence || "production"
49
+ end
50
+
51
+ def self.base_url
52
+ (environment.to_s == "sandbox") ? ::Square::Environment::SANDBOX : ::Square::Environment::PRODUCTION
53
+ end
54
+
55
+ def self.signing_secret
56
+ find_value_by_name(:square, :signing_secret)
57
+ end
58
+
59
+ # Square's HMAC covers (notification_url + body), so this must match the URL registered
60
+ # with Square; behind a proxy request.original_url may not.
61
+ def self.webhook_notification_url
62
+ find_value_by_name(:square, :webhook_notification_url)
63
+ end
64
+
65
+ def self.access_token(pay_customer, force_refresh: false)
66
+ unless access_token_provider.respond_to?(:call)
67
+ raise Pay::Square::Error, "Pay::Square.access_token_provider is not configured. Set it to a callable that returns a fresh Square OAuth access token for a Pay::Customer."
68
+ end
69
+
70
+ token = if access_token_provider.arity == 1 || access_token_provider.arity.zero?
71
+ access_token_provider.call(pay_customer)
72
+ else
73
+ access_token_provider.call(pay_customer, force_refresh)
74
+ end
75
+
76
+ raise Pay::Square::Error, "Pay::Square.access_token_provider returned a blank token for pay_customer #{pay_customer&.id}" if token.blank?
77
+ token
78
+ end
79
+
80
+ def self.client(pay_customer, force_refresh: false)
81
+ ::Square::Client.new(base_url: base_url, token: access_token(pay_customer, force_refresh: force_refresh))
82
+ end
83
+
84
+ # Retries ONCE with a force-refreshed token on a 401 and maps Square errors to
85
+ # Pay::Square::Error. Callers MUST build any idempotency_key outside this block so the
86
+ # retry reuses the same key and cannot double-charge.
87
+ def self.with_client(pay_customer)
88
+ attempts = 0
89
+ begin
90
+ attempts += 1
91
+ yield client(pay_customer, force_refresh: attempts > 1)
92
+ rescue ::Square::Errors::UnauthorizedError => e
93
+ if attempts < 2
94
+ Rails.logger.info { "[Pay::Square] access token rejected (401) for pay_customer #{pay_customer&.id}; forcing refresh and retrying" }
95
+ retry
96
+ end
97
+ raise Pay::Square::Error, e
98
+ rescue ::Square::Errors::ApiError => e
99
+ raise Pay::Square::Error, e
100
+ end
101
+ end
102
+
103
+ def self.construct_from_webhook_event(event)
104
+ case event
105
+ when Hash
106
+ ActiveSupport::InheritableOptions.new(event.map { |key, value| [key.to_sym, construct_from_webhook_event(value)] }.to_h)
107
+ when Array
108
+ event.map { |value| construct_from_webhook_event(value) }
109
+ else
110
+ event
111
+ end
112
+ end
113
+
114
+ def self.configure_webhooks
115
+ Pay::Webhooks.configure do |events|
116
+ events.subscribe "square.payment.created", Pay::Square::Webhooks::Payment.new
117
+ events.subscribe "square.payment.updated", Pay::Square::Webhooks::Payment.new
118
+ events.subscribe "square.refund.created", Pay::Square::Webhooks::Refund.new
119
+ events.subscribe "square.refund.updated", Pay::Square::Webhooks::Refund.new
120
+ events.subscribe "square.card.created", Pay::Square::Webhooks::Card.new
121
+ events.subscribe "square.card.updated", Pay::Square::Webhooks::Card.new
122
+ events.subscribe "square.card.disabled", Pay::Square::Webhooks::Card.new
123
+ events.subscribe "square.customer.created", Pay::Square::Webhooks::Customer.new
124
+ events.subscribe "square.customer.updated", Pay::Square::Webhooks::Customer.new
125
+ events.subscribe "square.customer.deleted", Pay::Square::Webhooks::Customer.new
126
+ end
127
+ end
128
+ end
129
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pay-square
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Willard Moore
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: pay
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '11.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '13'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '11.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '13'
32
+ description: Adds a Square payment processor to Pay. Charge on a merchant's own connected
33
+ Square account (per-merchant OAuth), with card-on-file, refunds, application fees,
34
+ and webhooks.
35
+ email:
36
+ - willardgmoore@gmail.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - MIT-LICENSE
43
+ - README.md
44
+ - Rakefile
45
+ - app/controllers/pay/webhooks/square_controller.rb
46
+ - config/routes.rb
47
+ - db/migrate/20250901000001_add_square_account_to_pay_models.rb
48
+ - lib/pay/square.rb
49
+ - lib/pay/square/attributes.rb
50
+ - lib/pay/square/charge.rb
51
+ - lib/pay/square/customer.rb
52
+ - lib/pay/square/engine.rb
53
+ - lib/pay/square/payment_method.rb
54
+ - lib/pay/square/version.rb
55
+ - lib/pay/square/webhooks/card.rb
56
+ - lib/pay/square/webhooks/customer.rb
57
+ - lib/pay/square/webhooks/payment.rb
58
+ - lib/pay/square/webhooks/refund.rb
59
+ homepage: https://github.com/Willardgmoore/pay-square
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ source_code_uri: https://github.com/Willardgmoore/pay-square
64
+ changelog_uri: https://github.com/Willardgmoore/pay-square/blob/main/CHANGELOG.md
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.1.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.6.9
81
+ specification_version: 4
82
+ summary: Square payment processor for the Pay gem
83
+ test_files: []