Skip to content

Commit

Permalink
Merge pull request #268 from solidusio/waiting-for-dev/partial_capture
Browse files Browse the repository at this point in the history
Handle partial captures
  • Loading branch information
waiting-for-dev authored Apr 11, 2023
2 parents be652e6 + f3632d5 commit d86549b
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 51 deletions.
9 changes: 9 additions & 0 deletions app/models/solidus_stripe/payment_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ class PaymentMethod < ::Spree::PaymentMethod

delegate :slug, to: :slug_entry

# @return [Spree::RefundReason] the reason used for refunds
# generated from Stripe.
# @see SolidusStripe::Configuration.refund_reason_name
def self.refund_reason
Spree::RefundReason.find_by!(
name: SolidusStripe.configuration.refund_reason_name
)
end

def partial_name
"stripe"
end
Expand Down
8 changes: 1 addition & 7 deletions app/subscribers/solidus_stripe/webhook/charge_subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def refund_payment(event)
payment: payment,
amount: amount,
transaction_id: payment_intent_id,
reason: default_refund_reason
reason: SolidusStripe::PaymentMethod.refund_reason
).tap do
SolidusStripe::LogEntries.payment_log(
payment,
Expand All @@ -55,12 +55,6 @@ def refund_payment(event)

private

def default_refund_reason
Spree::RefundReason.find_by!(
name: SolidusStripe.configuration.refund_reason_name
)
end

def refund_amount(new_stripe_total, currency, payment)
last_total = payment.refunds.sum(:amount)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# frozen_string_literal: true

require "solidus_stripe/money_to_stripe_amount_converter"

module SolidusStripe
module Webhook
# Handlers for Stripe payment_intent events.
class PaymentIntentSubscriber
include Omnes::Subscriber
include SolidusStripe::MoneyToStripeAmountConverter

handle :"stripe.payment_intent.succeeded", with: :complete_payment
handle :"stripe.payment_intent.succeeded", with: :capture_payment
handle :"stripe.payment_intent.payment_failed", with: :fail_payment
handle :"stripe.payment_intent.canceled", with: :void_payment

Expand All @@ -15,17 +18,26 @@ class PaymentIntentSubscriber
# Marks a Solidus payment associated to a Stripe payment intent as
# completed, adding a log entry about the event.
#
# In the case of a partial capture, a refund is created for the
# remaining amount and a log entry is added.
#
# @param event [SolidusStripe::Webhook::Event]
def complete_payment(event)
def capture_payment(event)
payment = extract_payment_from_event(event)
return if payment.completed?

payment.complete!.tap do
SolidusStripe::LogEntries.payment_log(
payment,
success: true,
message: "Capture was successful after payment_intent.succeeded webhook"
)
event.data.object.to_hash => {
amount: stripe_amount,
amount_received: stripe_amount_received,
currency:
}
if stripe_amount == stripe_amount_received
complete_payment(payment)
else
payment.transaction do
complete_payment(payment)
refund_payment(payment, stripe_amount, stripe_amount_received, currency)
end
end
end

Expand Down Expand Up @@ -74,6 +86,38 @@ def extract_payment_from_event(event)
payment_intent_id = event.data.object.id
Spree::Payment.find_by!(response_code: payment_intent_id)
end

def complete_payment(payment)
payment.complete!.tap do
SolidusStripe::LogEntries.payment_log(
payment,
success: true,
message: "Capture was successful after payment_intent.succeeded webhook"
)
end
end

def refund_payment(payment, stripe_amount, stripe_amount_received, currency)
refunded_amount = decimal_amount(stripe_amount - stripe_amount_received, currency)
Spree::Refund.create!(
payment: payment,
amount: refunded_amount,
transaction_id: payment.response_code,
reason: SolidusStripe::PaymentMethod.refund_reason
).tap do
SolidusStripe::LogEntries.payment_log(
payment,
success: true,
message: "Payment was refunded after payment_intent.succeeded webhook (#{_1.money})"
)
end
end

def decimal_amount(stripe_amount, currency)
stripe_amount
.then { to_solidus_amount(_1, currency) }
.then { solidus_subunit_to_decimal(_1, currency) }
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

RSpec.describe SolidusStripe::WebhooksController, type: %i[request webhook_request] do
describe "POST /create payment_intent.succeeded" do
it "transitions the associated payment to completed" do
it "captures the associated payment" do
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123")
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 1000,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,169 @@
require "solidus_stripe_spec_helper"

RSpec.describe SolidusStripe::Webhook::PaymentIntentSubscriber do
describe "#complete_payment" do
it "completes a pending payment" do
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123")
payment = create(:payment,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object
describe "#capture_payment" do
context "when a full capture is performed" do
it "completes a pending payment" do
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 1000,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.complete_payment(event)
described_class.new.capture_payment(event)

expect(payment.reload.state).to eq "completed"
expect(payment.reload.state).to eq "completed"
end

it "adds a log entry to the payment" do
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 1000,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.capture_payment(event)

details = payment.log_entries.last.parsed_details
expect(details.success?).to be(true)
expect(
details.message
).to eq "Capture was successful after payment_intent.succeeded webhook"
end
end

it "adds a log entry to the payment" do
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(id: "pi_123")
payment = create(:payment,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object
context "when a partial capture is performed" do
it "completes a pending payment" do
SolidusStripe::Seeds.refund_reasons
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 700,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.complete_payment(event)
described_class.new.capture_payment(event)

details = payment.log_entries.last.parsed_details
expect(details.success?).to be(true)
expect(
details.message
).to eq "Capture was successful after payment_intent.succeeded webhook"
expect(payment.reload.state).to eq "completed"
end

it "creates a refund for the unreceived amount" do
SolidusStripe::Seeds.refund_reasons
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 700,
currency: "usd"
)
payment = create(:payment,
amount: 7,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.capture_payment(event)

expect(payment.reload.refunds.count).to eq(1)
refund = payment.refunds.last
expect(refund.amount).to eq(3)
end

it "adds a log entry for the captured payment" do
SolidusStripe::Seeds.refund_reasons
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 700,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.capture_payment(event)

log_entries = payment.log_entries.map { [_1.parsed_details.success?, _1.parsed_details.message] }
expect(
log_entries
).to include([true, "Capture was successful after payment_intent.succeeded webhook"])
end

it "adds a log entry for the created refund" do
SolidusStripe::Seeds.refund_reasons
payment_method = create(:stripe_payment_method)
stripe_payment_intent = Stripe::PaymentIntent.construct_from(
id: "pi_123",
amount: 1000,
amount_received: 700,
currency: "usd"
)
payment = create(:payment,
amount: 10,
payment_method: payment_method,
response_code: stripe_payment_intent.id,
state: "pending")
event = SolidusStripe::Webhook::EventWithContextFactory.from_object(
payment_method: payment_method,
object: stripe_payment_intent,
type: "payment_intent.succeeded"
).solidus_stripe_object

described_class.new.capture_payment(event)

log_entries = payment.log_entries.map { [_1.parsed_details.success?, _1.parsed_details.message] }
expect(
log_entries
).to include([true, "Payment was refunded after payment_intent.succeeded webhook ($3.00)"])
end
end

it "does nothing if the payment is already completed" do
Expand All @@ -57,7 +181,7 @@
type: "payment_intent.succeeded",
).solidus_stripe_object

described_class.new.complete_payment(event)
described_class.new.capture_payment(event)

expect(payment.reload.state).to eq "completed"
expect(payment.log_entries.count).to be(0)
Expand Down

0 comments on commit d86549b

Please sign in to comment.