Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,28 @@ AllCops:
- dummy-app/**/*
- spec/dummy/**/*
- vendor/bundle/**/*

# Avoiding the nested namespaces can avoid some
# ambiguities, forcing full references.
# Also saves horizontal space.
Style/ClassAndModuleChildren:
Enabled: false

# Gain space by indenting from the start of the line,
# instead of the end of the method name.
Layout/MultilineMethodCallIndentation:
EnforcedStyle: indented

# More is more.
RSpec/MultipleExpectations:
Enabled: false

# This was suggesting to use the dangling comma instead,
# which is super cryptic 🙈.
Style/TrailingUnderscoreVariable:
Enabled: false

# System specs aren't describing any specific class/module.
RSpec/DescribeClass:
Exclude:
- spec/system/**/*
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ source 'https://rubygems.org'

gemspec

# Those are due to the "stdlib gemification" that's
# happening between versions of ruby.
gem 'stringio'
gem 'timeout'

gem 'listen'
2 changes: 2 additions & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web: unset PORT && bin/rails-sandbox server
installer: bin/rails-sandbox g solidus_stripe:install --force --watch
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ bin/rails generate solidus_stripe:install

## Development

Retrieve your API Key and Publishable Key from your [Stripe testing dashboard](https://stripe.com/docs/testing).

Set `SOLIDUS_STRIPE_API_KEY` and `SOLIDUS_STRIPE_PUBLISHABLE_KEY` environment variables (e.g. via `direnv`), this
will trigger the default initializer to create a static preference for SolidusStripe.

Run `bin/dev` to start both the sandbox rail server and the file watcher that will update the sandbox whenever

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if bin/dev also creates the payment method if needed? I think it would be a great UX improvement but don't know if that's easy to do. Thoughts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the static pref that for some reason was left out of the PR and bin/sandbox will auto-create the payment method connecting to the static pref if the same env var is present. 👍

a file is changed.

Visit `/admin/payments` and create a new Stripe payment using the static preferences.

### Testing the extension

First bundle your dependencies, then run `bin/rake`. `bin/rake` will default to building the dummy
Expand Down
35 changes: 35 additions & 0 deletions app/controllers/solidus_stripe/payment_intents_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require 'stripe'

class SolidusStripe::PaymentIntentsController < Spree::BaseController
include Spree::Core::ControllerHelpers::Order

def create
payment_method = current_order(create_order_if_necessary: true)
.available_payment_methods.find(params[:payment_method_id])

currency = current_order.currency
amount = SolidusStripe::Gateway::MoneyToStripeAmountConverter.to_stripe_amount(
current_order.display_total.money.fractional,
currency,
)

intent, _response = payment_method.gateway.client.request do
Stripe::PaymentIntent.create({
amount: amount,
currency: currency,
capture_method: 'manual',
})
end

# TODO: send the payment source information to the frontend so
# it can be used for the payment step form.
_payment_source = SolidusStripe::PaymentSource.create!(
stripe_payment_intent_id: intent.id,
payment_method: payment_method,
)

render json: { client_secret: intent.client_secret }
end
end
7 changes: 7 additions & 0 deletions app/models/solidus_stripe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module SolidusStripe
def self.table_name_prefix
"solidus_stripe_"
end
end
121 changes: 109 additions & 12 deletions app/models/solidus_stripe/gateway.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

require 'stripe'

# rubocop:disable Lint/UnusedMethodArgument, Style/MethodCallWithoutArgsParentheses
# Documentation about separate authorization and capture
# - https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=checkout#auth-and-capture
# - https://stripe.com/docs/charges/placing-a-hold
module SolidusStripe
class Gateway
def initialize(options)
Expand All @@ -13,31 +15,127 @@ def initialize(options)
@options = options
end

attr_reader :client

# Authorizes a certain amount on the provided payment source.
def authorize(money, source, options = {})
ActiveMerchant::Billing::Response.new()
#
# @see #purchase
def authorize(amount_in_cents, source, options = {})
payment_intent_options = options[:payment_intent_options].dup.to_h
payment_intent_options[:capture_method] = "manual"
purchase(amount_in_cents, source, options.merge(payment_intent_options: payment_intent_options))
end

# Captures a certain amount from a previously authorized transaction.
def capture(money, transaction_id, options = {})
ActiveMerchant::Billing::Response.new()
# Ref: https://stripe.com/docs/api/payment_intents/capture#capture_payment_intent
# Ref: https://stripe.com/docs/payments/capture-later
#
# @todo add support for capturing custom amounts
#
# @param _amount_in_cents [Integer] The fractional amount as defined by Spree::Money,
# although it's cents for most currencies some will have a different multiplier.
# Currently ignored (see "todo" section).
# @param _transaction_id [Object] (currently ignored)
# @param options [Hash]
# @option options [Spree::Payment] :originator the payment on which the #capture is being performed (required)
#
# @return ActiveMerchant::Billing::Response
def capture(_amount_in_cents, _transaction_id, options = {})
payment = options[:originator] or raise ArgumentError, "please provide a payment with the :originator option"
payment_intent_id = payment.source.stripe_payment_intent_id

raise ArgumentError, "missing transaction_id" unless payment_intent_id

unless payment_intent_id.start_with?('pi_')
raise ArgumentError, "the payment-intent id has the wrong format"
end

payment_intent, _response = client.request { Stripe::PaymentIntent.capture(payment_intent_id) }

ActiveMerchant::Billing::Response.new(
true, "Capture was successful", { 'stripe_payment_intent' => payment_intent.to_json }, {}
)
rescue Stripe::InvalidRequestError => e
ActiveMerchant::Billing::Response.new(
false, e.to_s, { 'json_response' => e.response.to_json }, {}
)
end

# Authorizes and captures a certain amount on the provided payment source.
def purchase(money, source, options = {})
ActiveMerchant::Billing::Response.new()
#
# @param amount_in_cents [Integer] The fractional amount as defined by Spree::Money,
# although it's cents for most currencies some will have a different multiplier.
# @param source [Spree::PaymentSource, nil] optionally, payment source from which to create the authorization
# @param options [Hash]
# @option options [Hash] :payment_intent_options options forwarded to `Stripe::PaymentIntent.create`
#
# @return ActiveMerchant::Billing::Response
def purchase(amount_in_cents, _source, options = {})
currency = options.fetch(:currency)

# Charge the Customer instead of the card:
payment_intent, _response = client.request do
Stripe::PaymentIntent.create({
amount: MoneyToStripeAmountConverter.to_stripe_amount(amount_in_cents, currency),
currency: currency,
**options[:payment_intent_options].to_h
})
end

ActiveMerchant::Billing::Response.new(
true, "PaymentIntent was created successfully", { 'stripe_payment_intent' => payment_intent.to_json }, {}
)
end

# Voids a previously authorized transaction, releasing the funds that are on hold.
# The source parameter is only needed for payment gateways that support payment profiles.
def void(transaction_id, source, options = {})
ActiveMerchant::Billing::Response.new()
#
# @param _transaction_id [Object] (currently ignored)
# @param source [Spree::PaymentSource, nil] optionally, payment source from which to create the authorization
# @param _options [Hash] (ignored)
#
# @return ActiveMerchant::Billing::Response
def void(_transaction_id, source, _options = {})
payment_intent, _response = client.request do
Stripe::PaymentIntent.cancel(source.stripe_payment_intent_id)
end

ActiveMerchant::Billing::Response.new(
true, "PaymentIntent was canceled successfully", { 'stripe_payment_intent' => payment_intent.to_json }, {}
)
rescue Stripe::InvalidRequestError => e
ActiveMerchant::Billing::Response.new(
false, e.to_s, { 'json_response' => e.response.to_json }, {}
)
end

# Refunds the provided amount on a previously captured transaction.
# The source parameter is only needed for payment gateways that support payment profiles.
def credit(money, source, transaction_id, options = {})
ActiveMerchant::Billing::Response.new()
#
# @param amount_in_cents [Integer] The fractional amount as defined by Spree::Money,
# although it's cents for most currencies some will have a different multiplier.
# @param source [Spree::PaymentSource, nil] optionally, payment source from which
# to create the authorization
# @param _transaction_id [Object] (currently ignored)
# @param options [Hash]
# @option options [Spree::Payment] :originator the payment on which the #capture is
# being performed (required if source is nil)
#
# @return ActiveMerchant::Billing::Response
def credit(amount_in_cents, source, _transaction_id, options = {})
currency = options.fetch(:currency)
source ||= options[:originator].source

refund, _response = client.request do
Stripe::Refund.create(
amount: MoneyToStripeAmountConverter.to_stripe_amount(amount_in_cents, currency),
payment_intent: source.stripe_payment_intent_id,
)
end

ActiveMerchant::Billing::Response.new(
true, "PaymentIntent was refunded successfully", { 'stripe_refund' => refund.to_json }, {}
)
end

module MoneyToStripeAmountConverter
Expand Down Expand Up @@ -105,4 +203,3 @@ def to_stripe_amount(fractional, currency)
end
end
end
# rubocop:enable Style/MethodCallWithoutArgsParentheses, Lint/UnusedMethodArgument
27 changes: 27 additions & 0 deletions app/models/solidus_stripe/payment_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
module SolidusStripe
class PaymentMethod < ::Spree::PaymentMethod
preference :api_key, :string
preference :publishable_key, :string

def partial_name
"stripe"
end

def cart_partial_name
"stripe"
end

def product_page_partial_name
"stripe"
end

def payment_source_class
PaymentSource
Expand All @@ -12,6 +25,20 @@ def gateway_class
Gateway
end

def create_profile(payment)
payment_intent = payment.source.payment_intent

if payment_intent && payment_intent.customer.blank?
payment.payment_method.gateway.client.request do
payment_intent.customer = Stripe::Customer.new email: payment.order.email
payment_intent.customer.save
payment_intent.save
end
end

self
end

def payment_profiles_supported?
true
end
Expand Down
18 changes: 18 additions & 0 deletions app/models/solidus_stripe/payment_source.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,23 @@

module SolidusStripe
class PaymentSource < ::Spree::PaymentSource
alias_attribute :gateway_payment_profile_id, :stripe_payment_intent_id
alias_attribute :gateway_payment_profile_id=, :stripe_payment_intent_id=

def payment_intent
return nil if stripe_payment_intent_id.blank?

payment_intent, _response = payment_method.gateway.client.request do
Stripe::PaymentIntent.retrieve(stripe_payment_intent_id)
end

payment_intent
end

def stripe_dashboard_url
path_prefix = '/test' if payment_method.preferred_test_mode

"https://dashboard.stripe.com#{path_prefix}/payments/#{stripe_payment_intent_id}"
end
end
end
16 changes: 16 additions & 0 deletions app/views/spree/admin/payments/source_views/_stripe.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<fieldset data-hook="credit_card">
<legend align="center"><%= SolidusStripe::PaymentSource.model_name.human %></legend>

<div class="row">
<div class="col-4">
<dl>
<dt><%= SolidusStripe::PaymentSource.human_attribute_name(:stripe_payment_intent_id) %>:</dt>
<dd><%= link_to(
payment.source.stripe_payment_intent_id,
payment.source.stripe_dashboard_url,
target: :_blank,
) %></dd>
</dl>
</div>
</div>
</fieldset>
8 changes: 8 additions & 0 deletions bin/dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env sh

if ! gem list foreman -i --silent; then
echo "Installing foreman..."
gem install foreman
fi

exec foreman start -f Procfile.dev "$@"
14 changes: 4 additions & 10 deletions bin/rspec
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
#!/usr/bin/env bash
#!/usr/bin/env ruby

set -e

bin/rails-dummy-app generate solidus_stripe:install --force --migrate --specs=all

cd dummy-app/
rspec "$@"
exit_status=$?
cd -
exit $exit_status
system "bin/rails-dummy-app generate solidus_stripe:install --force --migrate --specs=all --sync"
Dir.chdir "dummy-app/"
exec "rspec", *ARGV
Loading