diff --git a/app/commands/payments/stripe/handle_event.rb b/app/commands/payments/stripe/handle_event.rb index 69fc0b2d3b..2b5ca659fc 100644 --- a/app/commands/payments/stripe/handle_event.rb +++ b/app/commands/payments/stripe/handle_event.rb @@ -16,6 +16,10 @@ def call subscription_id: data_object['subscription'], payment_intent_id: data_object['payment_intent'] ) + + # The invoice is paid by now, so record the subscription in case + # it wasn't recorded when the payment intent succeeded. + Payments::Stripe::PaymentIntent::HandleSuccess.(id: data_object['payment_intent']) end end end diff --git a/app/commands/payments/stripe/payment_intent/cancel.rb b/app/commands/payments/stripe/payment_intent/cancel.rb index 83b86cfbbf..dd88003da0 100644 --- a/app/commands/payments/stripe/payment_intent/cancel.rb +++ b/app/commands/payments/stripe/payment_intent/cancel.rb @@ -1,9 +1,11 @@ class Payments::Stripe::PaymentIntent::Cancel include Mandate - initialize_with :id + initialize_with :user, :id def call + return unless belongs_to_user? + if invoice void_invoice! else @@ -24,9 +26,15 @@ def delete_subscription!(subscription_id) Rails.logger.error(e) end + def belongs_to_user? + user.stripe_customer_id.present? && payment_intent.customer == user.stripe_customer_id + end + + memoize + def payment_intent = Stripe::PaymentIntent.retrieve(id) + memoize def invoice - payment_intent = Stripe::PaymentIntent.retrieve(id) return nil unless payment_intent.invoice Stripe::Invoice.retrieve(payment_intent.invoice) diff --git a/app/commands/payments/stripe/payment_intent/handle_success.rb b/app/commands/payments/stripe/payment_intent/handle_success.rb index 038a8d6ed4..96a8038b85 100644 --- a/app/commands/payments/stripe/payment_intent/handle_success.rb +++ b/app/commands/payments/stripe/payment_intent/handle_success.rb @@ -9,8 +9,10 @@ def initialize(id: nil, payment_intent: nil) end def call + return unless payment_intent_succeeded? return unless user return unless should_record_payment? + return if subscription_data && !subscription_paid? subscription = Payments::Stripe::Subscription::Create.(user, subscription_data) if subscription_data Payments::Stripe::Payment::Create.(user, payment_intent, subscription:) @@ -19,6 +21,15 @@ def call private attr_reader :id + def payment_intent_succeeded? = payment_intent.status == "succeeded" + + # A subscription only counts once its first invoice has been paid. + def subscription_paid? + return true if invoice.status == "paid" + + %w[active trialing].include?(subscription_data.status) + end + memoize def user raise "No customer in the payment intent" unless payment_intent.customer @@ -27,11 +38,15 @@ def user end memoize - def subscription_data + def invoice return unless payment_intent.invoice - invoice = Stripe::Invoice.retrieve(payment_intent.invoice) - return unless invoice.subscription + Stripe::Invoice.retrieve(payment_intent.invoice) + end + + memoize + def subscription_data + return unless invoice&.subscription Stripe::Subscription.retrieve(invoice.subscription) end diff --git a/app/controllers/api/payments/payment_intents_controller.rb b/app/controllers/api/payments/payment_intents_controller.rb index dd3055436e..00c25a13a6 100644 --- a/app/controllers/api/payments/payment_intents_controller.rb +++ b/app/controllers/api/payments/payment_intents_controller.rb @@ -36,7 +36,7 @@ def succeeded end def failed - ::Payments::Stripe::PaymentIntent::Cancel.(params[:id]) + ::Payments::Stripe::PaymentIntent::Cancel.(current_user, params[:id]) render json: {} end diff --git a/test/commands/payments/stripe/handle_event_test.rb b/test/commands/payments/stripe/handle_event_test.rb new file mode 100644 index 0000000000..348ad0de3a --- /dev/null +++ b/test/commands/payments/stripe/handle_event_test.rb @@ -0,0 +1,42 @@ +require_relative '../test_base' + +class Payments::Stripe::HandleEventTest < Payments::TestBase + test "handles a succeeded payment intent" do + payment_intent = OpenStruct.new(id: SecureRandom.uuid) + event = OpenStruct.new(type: 'payment_intent.succeeded', data: OpenStruct.new(object: payment_intent)) + + Payments::Stripe::PaymentIntent::HandleSuccess.expects(:call).with(payment_intent:) + + Payments::Stripe::HandleEvent.(event) + end + + test "handles the first invoice of a subscription being paid" do + subscription_id = SecureRandom.uuid + payment_intent_id = SecureRandom.uuid + invoice = { + 'billing_reason' => 'subscription_create', + 'subscription' => subscription_id, + 'payment_intent' => payment_intent_id + } + event = OpenStruct.new(type: 'invoice.payment_succeeded', data: OpenStruct.new(object: invoice)) + + Payments::Stripe::Subscription::HandleCreated.expects(:call).with(subscription_id:, payment_intent_id:) + Payments::Stripe::PaymentIntent::HandleSuccess.expects(:call).with(id: payment_intent_id) + + Payments::Stripe::HandleEvent.(event) + end + + test "ignores later subscription invoices" do + invoice = { + 'billing_reason' => 'subscription_cycle', + 'subscription' => SecureRandom.uuid, + 'payment_intent' => SecureRandom.uuid + } + event = OpenStruct.new(type: 'invoice.payment_succeeded', data: OpenStruct.new(object: invoice)) + + Payments::Stripe::Subscription::HandleCreated.expects(:call).never + Payments::Stripe::PaymentIntent::HandleSuccess.expects(:call).never + + Payments::Stripe::HandleEvent.(event) + end +end diff --git a/test/commands/payments/stripe/payment_intent/cancel_test.rb b/test/commands/payments/stripe/payment_intent/cancel_test.rb index 984f297fcc..6dc0dba319 100644 --- a/test/commands/payments/stripe/payment_intent/cancel_test.rb +++ b/test/commands/payments/stripe/payment_intent/cancel_test.rb @@ -1,6 +1,11 @@ require_relative '../../test_base' class Payments::Stripe::PaymentIntent::CancelTest < Payments::TestBase + setup do + @customer_id = SecureRandom.uuid + @user = create :user, stripe_customer_id: @customer_id + end + test "cancels for subscription" do payment_intent_id = SecureRandom.uuid subscription_id = SecureRandom.uuid @@ -8,7 +13,8 @@ class Payments::Stripe::PaymentIntent::CancelTest < Payments::TestBase invoice = mock_stripe_invoice(invoice_id, subscription_id) payment_intent = mock_stripe_payment_intent( payment_intent_id, - invoice_id: + invoice_id:, + customer: @customer_id ) Stripe::PaymentIntent.expects(:retrieve).with(payment_intent_id).returns(payment_intent) @@ -16,7 +22,7 @@ class Payments::Stripe::PaymentIntent::CancelTest < Payments::TestBase Stripe::Subscription.expects(:cancel).with(subscription_id) Stripe::Invoice.expects(:void_invoice).with(invoice_id) - Payments::Stripe::PaymentIntent::Cancel.(payment_intent_id) + Payments::Stripe::PaymentIntent::Cancel.(@user, payment_intent_id) end test "does not void unopen invoices" do @@ -26,7 +32,8 @@ class Payments::Stripe::PaymentIntent::CancelTest < Payments::TestBase invoice = mock_stripe_invoice(invoice_id, subscription_id, status: 'closed') payment_intent = mock_stripe_payment_intent( payment_intent_id, - invoice_id: + invoice_id:, + customer: @customer_id ) Stripe::PaymentIntent.expects(:retrieve).with(payment_intent_id).returns(payment_intent) @@ -34,18 +41,46 @@ class Payments::Stripe::PaymentIntent::CancelTest < Payments::TestBase Stripe::Subscription.expects(:cancel).with(subscription_id) Stripe::Invoice.expects(:void_invoice).never - Payments::Stripe::PaymentIntent::Cancel.(payment_intent_id) + Payments::Stripe::PaymentIntent::Cancel.(@user, payment_intent_id) end test "cancels payments" do payment_intent_id = SecureRandom.uuid payment_intent = mock_stripe_payment_intent( - payment_intent_id + payment_intent_id, + customer: @customer_id ) Stripe::PaymentIntent.expects(:retrieve).with(payment_intent_id).returns(payment_intent) Stripe::PaymentIntent.expects(:cancel).with(payment_intent_id) - Payments::Stripe::PaymentIntent::Cancel.(payment_intent_id) + Payments::Stripe::PaymentIntent::Cancel.(@user, payment_intent_id) + end + + test "does nothing for another customer's payment intent" do + payment_intent_id = SecureRandom.uuid + payment_intent = mock_stripe_payment_intent( + payment_intent_id, + invoice_id: SecureRandom.uuid, + customer: SecureRandom.uuid + ) + + Stripe::PaymentIntent.expects(:retrieve).with(payment_intent_id).returns(payment_intent) + Stripe::Invoice.expects(:retrieve).never + Stripe::Subscription.expects(:cancel).never + Stripe::Invoice.expects(:void_invoice).never + Stripe::PaymentIntent.expects(:cancel).never + + Payments::Stripe::PaymentIntent::Cancel.(@user, payment_intent_id) + end + + test "does nothing if the user has no stripe customer" do + user = create :user + payment_intent_id = SecureRandom.uuid + + Stripe::PaymentIntent.expects(:retrieve).never + Stripe::PaymentIntent.expects(:cancel).never + + Payments::Stripe::PaymentIntent::Cancel.(user, payment_intent_id) end end diff --git a/test/commands/payments/stripe/payment_intent/handle_success_test.rb b/test/commands/payments/stripe/payment_intent/handle_success_test.rb new file mode 100644 index 0000000000..c7668b4d0e --- /dev/null +++ b/test/commands/payments/stripe/payment_intent/handle_success_test.rb @@ -0,0 +1,99 @@ +require_relative '../../test_base' + +class Payments::Stripe::PaymentIntent::HandleSuccessTest < Payments::TestBase + setup do + @customer_id = SecureRandom.uuid + @user = create :user, stripe_customer_id: @customer_id + end + + test "records a succeeded payment" do + payment_intent = mock_stripe_payment_intent(SecureRandom.uuid, customer: @customer_id, status: "succeeded") + Stripe::Checkout::Session.expects(:list).with({ payment_intent: payment_intent.id }).returns(OpenStruct.new(data: [])) + + Payments::Stripe::Subscription::Create.expects(:call).never + Payments::Stripe::Payment::Create.expects(:call).with(@user, payment_intent, subscription: nil) + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end + + test "retrieves the payment intent by id" do + payment_intent = mock_stripe_payment_intent(SecureRandom.uuid, customer: @customer_id, status: "succeeded") + Stripe::PaymentIntent.expects(:retrieve).with(payment_intent.id).returns(payment_intent) + Stripe::Checkout::Session.expects(:list).returns(OpenStruct.new(data: [])) + + Payments::Stripe::Payment::Create.expects(:call).with(@user, payment_intent, subscription: nil) + + Payments::Stripe::PaymentIntent::HandleSuccess.(id: payment_intent.id) + end + + %w[requires_payment_method requires_confirmation requires_action processing canceled].each do |status| + test "does not record a payment with status #{status}" do + payment_intent = mock_stripe_payment_intent(SecureRandom.uuid, customer: @customer_id, status:) + + Payments::Stripe::Subscription::Create.expects(:call).never + Payments::Stripe::Payment::Create.expects(:call).never + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end + end + + test "records a subscription and payment once the invoice is paid" do + invoice_id = SecureRandom.uuid + subscription_id = SecureRandom.uuid + payment_intent = mock_stripe_payment_intent( + SecureRandom.uuid, invoice_id:, customer: @customer_id, status: "succeeded" + ) + invoice = mock_stripe_invoice(invoice_id, subscription_id, status: "paid") + subscription_data = mock_stripe_subscription(subscription_id, 1500, status: "incomplete") + Stripe::Invoice.expects(:retrieve).with(invoice_id).returns(invoice) + Stripe::Subscription.expects(:retrieve).with(subscription_id).returns(subscription_data) + + subscription = create(:payments_subscription, user: @user) + Payments::Stripe::Subscription::Create.expects(:call).with(@user, subscription_data).returns(subscription) + Payments::Stripe::Payment::Create.expects(:call).with(@user, payment_intent, subscription:) + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end + + test "records a subscription and payment once the subscription is active" do + invoice_id = SecureRandom.uuid + subscription_id = SecureRandom.uuid + payment_intent = mock_stripe_payment_intent( + SecureRandom.uuid, invoice_id:, customer: @customer_id, status: "succeeded" + ) + invoice = mock_stripe_invoice(invoice_id, subscription_id, status: "open") + subscription_data = mock_stripe_subscription(subscription_id, 1500, status: "active") + Stripe::Invoice.expects(:retrieve).with(invoice_id).returns(invoice) + Stripe::Subscription.expects(:retrieve).with(subscription_id).returns(subscription_data) + + Payments::Stripe::Subscription::Create.expects(:call).with(@user, subscription_data) + Payments::Stripe::Payment::Create.expects(:call) + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end + + test "does not record an unpaid subscription" do + invoice_id = SecureRandom.uuid + subscription_id = SecureRandom.uuid + payment_intent = mock_stripe_payment_intent( + SecureRandom.uuid, invoice_id:, customer: @customer_id, status: "succeeded" + ) + invoice = mock_stripe_invoice(invoice_id, subscription_id, status: "open") + subscription_data = mock_stripe_subscription(subscription_id, 1500, status: "incomplete") + Stripe::Invoice.expects(:retrieve).with(invoice_id).returns(invoice) + Stripe::Subscription.expects(:retrieve).with(subscription_id).returns(subscription_data) + + Payments::Stripe::Subscription::Create.expects(:call).never + Payments::Stripe::Payment::Create.expects(:call).never + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end + + test "does nothing for an unknown customer" do + payment_intent = mock_stripe_payment_intent(SecureRandom.uuid, customer: SecureRandom.uuid, status: "succeeded") + + Payments::Stripe::Payment::Create.expects(:call).never + + Payments::Stripe::PaymentIntent::HandleSuccess.(payment_intent:) + end +end diff --git a/test/commands/payments/test_base.rb b/test/commands/payments/test_base.rb index 461b69af10..28dc9877b9 100644 --- a/test/commands/payments/test_base.rb +++ b/test/commands/payments/test_base.rb @@ -58,10 +58,12 @@ def mock_stripe_invoice(id, subscription_id, status: 'open', customer: nil) ) end - def mock_stripe_payment_intent(id, invoice_id: nil, payment_method: nil) + def mock_stripe_payment_intent(id, invoice_id: nil, payment_method: nil, customer: nil, status: nil) data = RecursiveOpenStruct.new(id:) data.invoice = invoice_id if invoice_id data.payment_method = payment_method if payment_method + data.customer = customer if customer + data.status = status if status data end end diff --git a/test/controllers/api/payments/payment_intents_controller_test.rb b/test/controllers/api/payments/payment_intents_controller_test.rb index bbabecc063..74ea464051 100644 --- a/test/controllers/api/payments/payment_intents_controller_test.rb +++ b/test/controllers/api/payments/payment_intents_controller_test.rb @@ -105,7 +105,7 @@ class API::Payments::PaymentIntentsControllerTest < API::BaseTestCase user = create :user id = SecureRandom.uuid - ::Payments::Stripe::PaymentIntent::Cancel.expects(:call).with(id) + ::Payments::Stripe::PaymentIntent::Cancel.expects(:call).with(user, id) setup_user(user) patch failed_api_payments_payment_intent_path(id), headers: @headers, as: :json