From f3d866c382cf91b3dde4ce2f1e00e9547a719c3d Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 30 Aug 2024 16:39:42 +0100 Subject: [PATCH] feat(dunning): Add update_payment_status for stripe payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Roadmap Task 👉 https://getlago.canny.io/feature-requests/p/send-reminders-for-overdue-invoices ## Context We want to be able to manually request payment of the overdue balance and send emails for reminders. ## Description The goal of this change is to update the status of stripe payments. --- .../payments/stripe_service.rb | 91 ++++++-- .../payments/gocardless_service_spec.rb | 5 +- .../payments/stripe_service_spec.rb | 218 +++++++++++++++++- 3 files changed, 293 insertions(+), 21 deletions(-) diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index f61e649253d..3248e6181b0 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -47,11 +47,11 @@ def create payable_payment_status = payable_payment_status(payment.status) update_payable_payment_status( payment_status: payable_payment_status, - processing: payment.status == 'processing' + processing: payment.status == "processing" ) update_invoices_payment_status( payment_status: payable_payment_status, - processing: payment.status == 'processing' + processing: payment.status == "processing" ) end @@ -60,7 +60,7 @@ def create rescue Stripe::AuthenticationError, Stripe::CardError, Stripe::InvalidRequestError, Stripe::PermissionError => e # NOTE: Do not mark the payable as failed if the amount is too small for Stripe # For now we keep it as pending. - return result if e.code == 'amount_too_small' + return result if e.code == "amount_too_small" deliver_error_webhook(e) update_payable_payment_status(payment_status: :failed, deliver_webhook: false) @@ -82,13 +82,39 @@ def generate_payment_url } ) - result.payment_url = result_url['url'] + result.payment_url = result_url["url"] result rescue Stripe::CardError, Stripe::InvalidRequestError, Stripe::AuthenticationError, Stripe::PermissionError => e deliver_error_webhook(e) - result.single_validation_failure!(error_code: 'payment_provider_error') + result.single_validation_failure!(error_code: "payment_provider_error") + end + + def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {}) + # TODO: do we have one-time payments for payment requests? + payment = if metadata[:payment_type] == "one-time" + create_payment(provider_payment_id:, metadata:) + else + Payment.find_by(provider_payment_id:) + end + + return handle_missing_payment(organization_id, metadata) unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.update!(status:) + + processing = status == "processing" + payment_status = payable_payment_status(status) + update_payable_payment_status(payment_status:, processing:) + update_invoices_payment_status(payment_status:, processing:) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) end private @@ -197,15 +223,19 @@ def payable_payment_status(payment_status) end def update_payable_payment_status(payment_status:, deliver_webhook: true, processing: false) - payable.update!( - payment_status:, - # NOTE: A proper `processing` payment status should be introduced for payment_requests - ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded - ) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + # NOTE: A proper `processing` payment status should be introduced for payment_requests + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! end def update_invoices_payment_status(payment_status:, deliver_webhook: true, processing: false) - payable.invoices.each do |invoice| + result.payable.invoices.each do |invoice| Invoices::UpdateService.call( invoice: invoice, params: { @@ -232,7 +262,7 @@ def payment_url_payload } } ], - mode: 'payment', + mode: "payment", success_url: success_redirect_url, customer: customer.stripe_customer.provider_customer_id, payment_method_types: customer.stripe_customer.provider_payment_methods, @@ -242,12 +272,47 @@ def payment_url_payload lago_customer_id: customer.id, lago_payment_request_id: payable.id, lago_invoice_ids: payable.invoice_ids, - payment_type: 'one-time' + payment_type: "one-time" } } } end + def handle_missing_payment(organization_id, metadata) + # NOTE: Payment was not initiated by lago + return result unless metadata&.key?(:lago_payment_request_id) + + # NOTE: Payment Request does not belong to this lago organization + # It means the same Stripe secret key is used for multiple organizations + payment_request = PaymentRequest.find_by(id: metadata[:lago_payment_request_id], organization_id:) + return result unless payment_request + + # NOTE: Payment Request exists but payment status is failed + return result if payment_request.payment_failed? + + result.not_found_failure!(resource: "stripe_payment") + end + + def create_payment(provider_payment_id:, metadata:) + @payable = PaymentRequest.find_by(id: metadata[:lago_payment_request_id]) + + unless payable + result.not_found_failure!(resource: "payment_request") + return + end + + payable.increment_payment_attempts! + + Payment.new( + payable:, + payment_provider_id: stripe_payment_provider.id, + payment_provider_customer_id: customer.stripe_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency&.upcase, + provider_payment_id: + ) + end + def deliver_error_webhook(stripe_error) DeliverErrorWebhookService.call_async(payable, { provider_customer_id: customer.stripe_customer.provider_customer_id, diff --git a/spec/services/payment_requests/payments/gocardless_service_spec.rb b/spec/services/payment_requests/payments/gocardless_service_spec.rb index fb39f0738ef..83308db80d4 100644 --- a/spec/services/payment_requests/payments/gocardless_service_spec.rb +++ b/spec/services/payment_requests/payments/gocardless_service_spec.rb @@ -249,6 +249,7 @@ let(:provider_payment_id) { "ch_123456" } before do + allow(SegmentTrackJob).to receive(:perform_later) allow(SendWebhookJob).to receive(:perform_later) payment end @@ -329,10 +330,10 @@ end end - context "with invalid status", :aggregate_failures do + context "with invalid status" do let(:status) { "invalid-status" } - it "does not update the payment_status of payment_request, invoice and payment" do + it "does not update the payment_status of payment_request, invoice and payment", :aggregate_failures do expect { gocardless_service.update_payment_status(provider_payment_id:, status:) }.to not_change { payment_request.reload.payment_status } diff --git a/spec/services/payment_requests/payments/stripe_service_spec.rb b/spec/services/payment_requests/payments/stripe_service_spec.rb index 677826f2b41..42a96bfea1f 100644 --- a/spec/services/payment_requests/payments/stripe_service_spec.rb +++ b/spec/services/payment_requests/payments/stripe_service_spec.rb @@ -31,7 +31,7 @@ organization:, customer:, total_amount_cents: 200, - currency: 'EUR', + currency: "EUR", ready_for_payment_processing: true ) end @@ -42,12 +42,12 @@ organization:, customer:, total_amount_cents: 599, - currency: 'EUR', + currency: "EUR", ready_for_payment_processing: true ) end - describe ".create" do + describe "#create" do let(:provider_customer_service) { instance_double(PaymentProviderCustomers::StripeService) } let(:provider_customer_service_result) do @@ -150,7 +150,7 @@ end end - context 'with 0 amount' do + context "with 0 amount" do let(:payment_request) do create( :payment_request, @@ -168,11 +168,11 @@ organization:, customer:, total_amount_cents: 0, - currency: 'EUR' + currency: "EUR" ) end - it 'does not creates a stripe payment', :aggregate_failures do + it "does not creates a stripe payment", :aggregate_failures do result = stripe_service.create expect(result).to be_success @@ -422,4 +422,210 @@ end end end + + describe "#update_payment_status" do + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: + ) + end + + let(:provider_payment_id) { "ch_123456" } + + before do + allow(SegmentTrackJob).to receive(:perform_later) + allow(SendWebhookJob).to receive(:perform_later) + payment + end + + it "updates the payment, payment_request and invoice payment_status", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + + context "when status is failed" do + it "updates the payment, payment_request and invoice status", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "failed" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + end + end + + context "when payment_request and invoice is already payment_succeeded" do + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoice, payment_request and payment" do + expect { + stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded" + ) + }.to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded" + ) + + expect(result).to be_success + end + end + + context "with invalid status" do + let(:status) { "invalid-status" } + + it "does not update the payment_status of payment_request, invoice and payment", :aggregate_failures do + expect { + stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: + ) + }.to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and change { payment.reload.status }.to(status) + end + + it "returns an error", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payment_status) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + before do + stripe_payment_provider + stripe_customer + end + + it "creates a payment and updates payment request and invoice payment status", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded", + metadata: {lago_payment_request_id: payment_request.id, payment_type: "one-time"} + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + + context "when payment request is not found" do + it "raises a not found failure", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded", + metadata: {lago_payment_request_id: "invalid", payment_type: "one-time"} + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("payment_request_not_found") + end + end + end + + context "when payment is not found" do + let(:payment) { nil } + + it "returns an empty result", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded" + ) + + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "with payment request id in metadata" do + it "returns an empty result", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded", + metadata: {lago_payment_request_id: SecureRandom.uuid} + ) + + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "when the payment request is found for organization" do + it "returns a not found failure", :aggregate_failures do + result = stripe_service.update_payment_status( + organization_id: organization.id, + provider_payment_id:, + status: "succeeded", + metadata: {lago_payment_request_id: payment_request.id} + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("stripe_payment_not_found") + end + end + end + end + end end