From ed0daa171eac3a8ab3b31bb2639affc4ca1bdb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Romain=20Semp=C3=A9?= Date: Mon, 19 Aug 2024 19:56:15 +0200 Subject: [PATCH] feat(dunning): Add skeleton for creating a payment request (#2426) ## Context We want to be able to manually request payment of the overdue balance and send emails for reminders. https://getlago.canny.io/feature-requests/p/send-reminders-for-overdue-invoices ## Description The goal of this PR is to add the graphql mutation and the API endpoint for creating a payment request. --- app/config/permissions/definition.yml | 1 + app/config/permissions/role-finance.yml | 1 + app/config/permissions/role-manager.yml | 1 + .../api/v1/payment_requests_controller.rb | 21 ++++ .../mutations/payment_requests/create.rb | 23 ++++ app/graphql/types/mutation_type.rb | 2 + .../types/payment_requests/create_input.rb | 14 +++ .../payment_requests/create_service.rb | 39 ++++++ config/routes.rb | 2 +- schema.graphql | 24 ++++ schema.json | 118 ++++++++++++++++++ .../mutations/payment_requests/create_spec.rb | 47 +++++++ .../v1/payment_requests_controller_spec.rb | 41 ++++++ 13 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/payment_requests/create.rb create mode 100644 app/graphql/types/payment_requests/create_input.rb create mode 100644 app/services/payment_requests/create_service.rb create mode 100644 spec/graphql/mutations/payment_requests/create_spec.rb diff --git a/app/config/permissions/definition.yml b/app/config/permissions/definition.yml index d6739b33f78..0b03e69fd52 100644 --- a/app/config/permissions/definition.yml +++ b/app/config/permissions/definition.yml @@ -86,3 +86,4 @@ organization: delete: payment_requests: view: true + create: diff --git a/app/config/permissions/role-finance.yml b/app/config/permissions/role-finance.yml index 602e10986e4..573d2d20b90 100644 --- a/app/config/permissions/role-finance.yml +++ b/app/config/permissions/role-finance.yml @@ -62,3 +62,4 @@ organization: delete: false payment_requests: view: true + create: true diff --git a/app/config/permissions/role-manager.yml b/app/config/permissions/role-manager.yml index 2509396771a..bdd90538173 100644 --- a/app/config/permissions/role-manager.yml +++ b/app/config/permissions/role-manager.yml @@ -62,3 +62,4 @@ organization: delete: false payment_requests: view: true + create: true diff --git a/app/controllers/api/v1/payment_requests_controller.rb b/app/controllers/api/v1/payment_requests_controller.rb index bc8684b2130..7e57f34ef4d 100644 --- a/app/controllers/api/v1/payment_requests_controller.rb +++ b/app/controllers/api/v1/payment_requests_controller.rb @@ -3,6 +3,19 @@ module Api module V1 class PaymentRequestsController < Api::BaseController + def create + result = PaymentRequests::CreateService.call( + organization: current_organization, + params: create_params.to_h.deep_symbolize_keys + ) + + if result.success? + render(json: ::V1::PaymentRequestSerializer.new(result.payment_request, root_name: "payment_request")) + else + render_error_response(result) + end + end + def index result = PaymentRequestsQuery.call( organization: current_organization, @@ -30,6 +43,14 @@ def index private + def create_params + params.require(:payment_request).permit( + :email, + :external_customer_id, + :lago_invoice_ids + ) + end + def index_filters params.permit(:external_customer_id) end diff --git a/app/graphql/mutations/payment_requests/create.rb b/app/graphql/mutations/payment_requests/create.rb new file mode 100644 index 00000000000..abbf9404998 --- /dev/null +++ b/app/graphql/mutations/payment_requests/create.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module PaymentRequests + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_requests:create" + + graphql_name "CreatePaymentRequest" + description "Creates a payment request" + + input_object_class Types::PaymentRequests::CreateInput + type Types::PaymentRequests::Object + + def resolve(**args) + result = ::PaymentRequests::CreateService.call(organization: current_organization, params: args) + result.success? ? result.payment_request : result_error(result) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 7ce3a86a52b..a382f8a6b78 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -107,6 +107,8 @@ class MutationType < Types::BaseObject field :revoke_membership, mutation: Mutations::Memberships::Revoke field :update_membership, mutation: Mutations::Memberships::Update + field :create_payment_request, mutation: Mutations::PaymentRequests::Create + field :create_password_reset, mutation: Mutations::PasswordResets::Create field :reset_password, mutation: Mutations::PasswordResets::Reset diff --git a/app/graphql/types/payment_requests/create_input.rb b/app/graphql/types/payment_requests/create_input.rb new file mode 100644 index 00000000000..fbbd473d915 --- /dev/null +++ b/app/graphql/types/payment_requests/create_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PaymentRequests + class CreateInput < Types::BaseInputObject + graphql_name "PaymentRequestCreateInput" + + argument :external_customer_id, String, required: true + + argument :email, String, required: false + argument :lago_invoice_ids, [String], required: false + end + end +end diff --git a/app/services/payment_requests/create_service.rb b/app/services/payment_requests/create_service.rb new file mode 100644 index 00000000000..ce70de77e4f --- /dev/null +++ b/app/services/payment_requests/create_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentRequests + class CreateService < BaseService + def initialize(organization:, params:) + @organization = organization + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + + ActiveRecord::Base.transaction do + # TODO: Create payable group for the overdue invoices + + # TODO: Create payment request for the payable group + + # TODO: Send payment_request.created webhook + + # TODO: When payment provider is set: Create payment intent for the overdue invoices + # TODO: When payment provider is not set: Send email to the customer + + # result.payment_request = payment_request + end + + result + end + + private + + attr_reader :organization, :params + + def customer + @customer ||= organization.customers.find_by(external_id: params[:external_customer_id]) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 54bc1f390df..77d602e64b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,7 +61,7 @@ put :refresh, on: :member put :finalize, on: :member end - resources :payment_requests, only: %i[index] + resources :payment_requests, only: %i[create index] resources :plans, param: :code resources :taxes, param: :code resources :wallet_transactions, only: :create diff --git a/schema.graphql b/schema.graphql index df79f0db168..dc4dd41324c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -4353,6 +4353,16 @@ type Mutation { input: CreatePasswordResetInput! ): CreatePasswordResetPayload + """ + Creates a payment request + """ + createPaymentRequest( + """ + Parameters for CreatePaymentRequest + """ + input: PaymentRequestCreateInput! + ): PaymentRequest + """ Creates a new Plan """ @@ -5261,6 +5271,19 @@ type PaymentRequestCollection { metadata: CollectionMetadata! } +""" +Autogenerated input type of CreatePaymentRequest +""" +input PaymentRequestCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String + externalCustomerId: String! + lagoInvoiceIds: [String!] +} + """ Permissions Type """ @@ -5318,6 +5341,7 @@ type Permissions { organizationTaxesView: Boolean! organizationUpdate: Boolean! organizationView: Boolean! + paymentRequestsCreate: Boolean! paymentRequestsView: Boolean! plansCreate: Boolean! plansDelete: Boolean! diff --git a/schema.json b/schema.json index 4a7888a1a36..042e8f54997 100644 --- a/schema.json +++ b/schema.json @@ -21853,6 +21853,35 @@ } ] }, + { + "name": "createPaymentRequest", + "description": "Creates a payment request", + "type": { + "kind": "OBJECT", + "name": "PaymentRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for CreatePaymentRequest", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PaymentRequestCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "createPlan", "description": "Creates a new Plan", @@ -25485,6 +25514,77 @@ "inputFields": null, "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "PaymentRequestCreateInput", + "description": "Autogenerated input type of CreatePaymentRequest", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lagoInvoiceIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "OBJECT", "name": "Permissions", @@ -26448,6 +26548,24 @@ ] }, + { + "name": "paymentRequestsCreate", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "paymentRequestsView", "description": null, diff --git a/spec/graphql/mutations/payment_requests/create_spec.rb b/spec/graphql/mutations/payment_requests/create_spec.rb new file mode 100644 index 00000000000..9edac7801ce --- /dev/null +++ b/spec/graphql/mutations/payment_requests/create_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentRequests::Create, type: :graphql do + let(:required_permission) { "payment_requests:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice1) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + + let(:input) do + { + email: "john.doe@example.com", + externalCustomerId: customer.external_id, + lagoInvoiceIds: [invoice1.id, invoice2.id] + } + end + + let(:mutation) do + <<-GQL + mutation($input: PaymentRequestCreateInput!) { + createPaymentRequest(input: $input) { + id + email + customer { id } + invoices { id } + } + } + GQL + end + + it "creates a payment request" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]).to include( + "createPaymentRequest" => nil + ) + end +end diff --git a/spec/requests/api/v1/payment_requests_controller_spec.rb b/spec/requests/api/v1/payment_requests_controller_spec.rb index 5f41059e81e..7a48a6057ff 100644 --- a/spec/requests/api/v1/payment_requests_controller_spec.rb +++ b/spec/requests/api/v1/payment_requests_controller_spec.rb @@ -5,6 +5,47 @@ RSpec.describe Api::V1::PaymentRequestsController, type: :request do let(:organization) { create(:organization) } + describe "create" do + let(:customer) { create(:customer, organization:) } + let(:params) do + { + email: customer.email, + external_customer_id: customer.external_id + } + end + + context "when customer is not found" do + let(:params) do + {external_customer_id: "unknown"} + end + + it "returns a not found error" do + post_with_token(organization, "/api/v1/payment_requests", {payment_request: params}) + expect(response).to have_http_status(:not_found) + end + end + + it "delegates to PaymentRequests::CreateService", :aggregate_failures do + payment_request = create(:payment_request) + allow(PaymentRequests::CreateService).to receive(:call).and_return( + BaseService::Result.new.tap { |r| r.payment_request = payment_request } + ) + + post_with_token(organization, "/api/v1/payment_requests", {payment_request: params}) + + expect(PaymentRequests::CreateService).to have_received(:call).with( + organization:, + params: { + email: customer.email, + external_customer_id: customer.external_id + } + ) + + expect(response).to have_http_status(:success) + expect(json[:payment_request][:lago_id]).to eq(payment_request.id) + end + end + describe "index" do it "returns organization's payment requests", :aggregate_failures do first_customer = create(:customer, organization:)