From 04c838f8b87ea75815256a29bf5543adf05bc62b Mon Sep 17 00:00:00 2001 From: Julien Bourdeau Date: Fri, 31 Jan 2025 11:15:17 +0100 Subject: [PATCH] feat(boleto): Introduce Boleto as a valid Stripe Payment Method (#3111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context We're adding support for Boleto payment method through Stripe 🇧🇷 ## Description Boleto requires 2 step. First the payment is initiated on Stripe and the customer receive a barcode to go pay IRL. This PR has very little change because most of it already works and is already tested 😅 but it requires a fair amount of manual QA. To test the integration, you can use [different email address as described in Stripe docs](https://docs.stripe.com/payments/boleto/accept-a-payment#test-integration). By default, boleto payment are completed after 3 minutes. ## TODO - [x] Add `boleto` to `PaymentProviderCustomers::StripeCustomer::PAYMENT_METHODS` - [x] GQL: `boleto` is a valid payment method for a customer - [x] API: `boleto` is a valid payment method for a customer - [x] When Lago creates a invoice and tries to collect it via stripe - [x] Boleto is a valid payment method - [x] A Lago `payment.requires_action` webhook is sent - [x] Once the invoice is paid, Stripe sends payment_intent.succeded webhook and the invoice is marked as paid - [x] When using a stripe payment URL to pay an invoice - [x] Boleto is a valid payment method - [x] No webhook is sent for `next_action` since the customer is “on session” - [x] Boleto is saved as a customer payment method (See https://github.com/getlago/lago-api/pull/3107) - [x] Once the invoice is paid, Stripe sends payment_intent.succeded webhook and the invoice is marked as paid - [x] When setting up payment method (`POST /customers/:id/checkout_url`) - [x] Boleto is a valid payment method - [x] No webhook is sent for `next_action` because there is no payment at this stage, just setup - [x] When creating a PaymentRequest (`POST /payment_requests`) - [x] If Boleto is setup, we try to collect payment with Boleto - [x] A Lago `payment.requires_action` webhook is sent - [x] No `PaymentRequestMailer` email is sent ## Screenshots ### Create new customer, without payment method, create invoice and use invoice payment_url to pay ![CleanShot 2025-01-29 at 13 46 15@2x](https://github.com/user-attachments/assets/449f526e-d61b-4318-9803-a902ba8bee3d) ![CleanShot 2025-01-29 at 13 46 39@2x](https://github.com/user-attachments/assets/cc129306-ad42-4620-a730-1b5dd89077da) ![CleanShot 2025-01-29 at 13 46 59@2x](https://github.com/user-attachments/assets/2aa3bc56-910b-4c7b-bc66-10e0388c3ec8) ![CleanShot 2025-01-29 at 13 47 37@2x](https://github.com/user-attachments/assets/2dd885b4-f766-493b-88c6-276c22767c6d) ![CleanShot 2025-01-29 at 13 47 47@2x](https://github.com/user-attachments/assets/512bfb7c-4152-4f81-a480-f5f47818fe5a) ![CleanShot 2025-01-29 at 13 51 05@2x](https://github.com/user-attachments/assets/80c0776e-a6c4-4659-94ca-539ca7eb6466) ![CleanShot 2025-01-29 at 13 54 52@2x](https://github.com/user-attachments/assets/ea2b56fe-5e60-4842-8ba6-3ef11cef473a) ### Create new customer and set up payment method ![CleanShot 2025-01-29 at 13 10 14@2x](https://github.com/user-attachments/assets/415f02cb-fa4c-4f78-ad74-d1dd7b8a17a3) ![CleanShot 2025-01-29 at 13 10 33@2x](https://github.com/user-attachments/assets/7204c141-d571-4cbd-8110-be66c938e674) ![CleanShot 2025-01-29 at 13 11 01@2x](https://github.com/user-attachments/assets/dec9cc16-e4e9-49e2-a81b-54b485f4c912) ![CleanShot 2025-01-29 at 13 11 47@2x](https://github.com/user-attachments/assets/4c6a1778-2cdb-4017-938c-6a38546fbdce) ![CleanShot 2025-01-29 at 13 14 24@2x](https://github.com/user-attachments/assets/f472f4a2-f3c1-430d-8dce-4d096d31bb68) ### Invoice collection with Boleto set up ![CleanShot 2025-01-29 at 13 35 32@2x](https://github.com/user-attachments/assets/8fe1158a-b100-4a6c-b6f3-c6eb591cc677) ![CleanShot 2025-01-29 at 13 35 59@2x](https://github.com/user-attachments/assets/301773a7-30b3-4b2a-919b-7d30d1df416d) ![CleanShot 2025-01-29 at 13 38 58@2x](https://github.com/user-attachments/assets/c097af4d-0851-4298-a59f-95b06909fda4) ![CleanShot 2025-01-29 at 13 39 59@2x](https://github.com/user-attachments/assets/0ec502fa-0870-4d4b-a7ea-8a353a31ed7e) --- .../stripe_customer.rb | 2 +- schema.graphql | 1 + schema.json | 6 ++ .../customers/customer_type_enum_spec.rb | 2 +- .../provider_payment_methods_enum_spec.rb | 9 +++ .../stripe/payments/create_service_spec.rb | 72 ++++++++++++++++--- 6 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb diff --git a/app/models/payment_provider_customers/stripe_customer.rb b/app/models/payment_provider_customers/stripe_customer.rb index 11578ae64ac..89048fa2cc6 100644 --- a/app/models/payment_provider_customers/stripe_customer.rb +++ b/app/models/payment_provider_customers/stripe_customer.rb @@ -2,7 +2,7 @@ module PaymentProviderCustomers class StripeCustomer < BaseCustomer - PAYMENT_METHODS = %w[card sepa_debit us_bank_account bacs_debit link].freeze + PAYMENT_METHODS = %w[card sepa_debit us_bank_account bacs_debit link boleto].freeze validates :provider_payment_methods, presence: true validate :allowed_provider_payment_methods diff --git a/schema.graphql b/schema.graphql index f0a4ebe7c9a..389a0939e2b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6491,6 +6491,7 @@ input ProviderCustomerInput { enum ProviderPaymentMethodsEnum { bacs_debit + boleto card link sepa_debit diff --git a/schema.json b/schema.json index 6b8ffd74503..c1a5fd8da27 100644 --- a/schema.json +++ b/schema.json @@ -31199,6 +31199,12 @@ "description": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "boleto", + "description": null, + "isDeprecated": false, + "deprecationReason": null } ] }, diff --git a/spec/graphql/types/customers/customer_type_enum_spec.rb b/spec/graphql/types/customers/customer_type_enum_spec.rb index 06758846262..11feac2161f 100644 --- a/spec/graphql/types/customers/customer_type_enum_spec.rb +++ b/spec/graphql/types/customers/customer_type_enum_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Types::Customers::CustomerTypeEnum do - it 'enumerizes the correct values' do + it 'enumerates the correct values' do expect(described_class.values.keys).to match_array(%w[company individual]) end end diff --git a/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb b/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb new file mode 100644 index 00000000000..21c476a2bc3 --- /dev/null +++ b/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::PaymentProviderCustomers::ProviderPaymentMethodsEnum do + it 'enumerates the correct values' do + expect(described_class.values.keys).to match_array(%w[card sepa_debit us_bank_account bacs_debit link boleto]) + end +end diff --git a/spec/services/payment_providers/stripe/payments/create_service_spec.rb b/spec/services/payment_providers/stripe/payments/create_service_spec.rb index c26a4cb73e5..2892917f4f2 100644 --- a/spec/services/payment_providers/stripe/payments/create_service_spec.rb +++ b/spec/services/payment_providers/stripe/payments/create_service_spec.rb @@ -56,13 +56,13 @@ File.read(Rails.root.join("spec/fixtures/stripe/customer_retrieve_response.json")) end - let(:stripe_payment_intent) do - Stripe::PaymentIntent.construct_from( - id: "ch_123456", + let(:stripe_payment_intent_data) do + { + id: "pi_123456", status: payment_status, amount: invoice.total_amount_cents, currency: invoice.currency - ) + } end let(:payment_status) { "succeeded" } @@ -71,8 +71,10 @@ stripe_payment_provider stripe_customer - allow(Stripe::PaymentIntent).to receive(:create) - .and_return(stripe_payment_intent) + allow(Stripe::PaymentIntent).to receive(:create).and_call_original + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .to_return(body: stripe_payment_intent_data.to_json) + allow(SegmentTrackJob).to receive(:perform_later) allow(Invoices::PrepaidCreditJob).to receive(:perform_later) @@ -206,6 +208,9 @@ expect(result.error_message).to eq("error") expect(result.error_code).to be_nil + + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") end end @@ -247,6 +252,53 @@ end end + context 'when invoice amount is too big to pay with Boleto' do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 100_000_00, + currency: "BRL", + ready_for_payment_processing: true + ) + end + + before do + subscription + + WebMock.stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .to_return(status: 400, body: { + error: { + code: "amount_too_large", + doc_url: "https://stripe.com/docs/error-codes/amount-too-large", + message: "Amount must be no more than R$ 49,999.99 brl", + param: "amount", + request_log_url: "https://dashboard.stripe.com/test/logs/req_WAmkqXs7ajMNAU?t=1738144303", + type: "invalid_request_error" + } + }.to_json) + end + + it "returns an empty result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + expect(result.error.error_message).to eq("Amount must be no more than R$ 49,999.99 brl") + + expect(result.error_message).to eq("Amount must be no more than R$ 49,999.99 brl") + expect(result.error_code).to eq("amount_too_large") + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + context "when payment status is processing" do let(:payment_status) { "processing" } @@ -271,16 +323,16 @@ context "when customers country is IN" do let(:payment_status) { "requires_action" } - let(:stripe_payment_intent) do - Stripe::PaymentIntent.construct_from( - id: "ch_123456", + let(:stripe_payment_intent_data) do + { + id: "pi_123456", status: payment_status, amount: invoice.total_amount_cents, currency: invoice.currency, next_action: { redirect_to_url: {url: "https://foo.bar"} } - ) + } end before do