Skip to content

Commit

Permalink
Feat manual payments controller (#3045)
Browse files Browse the repository at this point in the history
## Roadmap Task

👉  https://getlago.canny.io/feature-requests/p/log-partial-payments

## Context

This PR introduces a new endpoint to list all payments. Additionally, it
adds the ability to filter payments by a specific invoice_id, allowing
users to narrow down the results to payments associated with a
particular invoice. This enhancement improves the flexibility and
usability of the payments listing functionality.
It also adds `create` and `show` actions in the PaymentsController.

## Description

**Added Endpoint:**

- Created an endpoint to list, show and create payments.
- Filter by invoice_id:
- Added functionality to filter payments based on the provided
invoice_id.
- Included a validation step to ensure the invoice_id is in the correct
UUID format before executing the query.

**Query Updates:**

- Refactored PaymentsQuery to handle dynamic filtering by invoice_id.
- Ensured that the query properly joins related tables, such as invoices
and payment_requests, to support filtering.

---------

Co-authored-by: brunomiguelpinto <[email protected]>
  • Loading branch information
ivannovosad and brunomiguelpinto authored Jan 16, 2025
1 parent 36315a9 commit b28b40d
Show file tree
Hide file tree
Showing 17 changed files with 682 additions and 50 deletions.
14 changes: 14 additions & 0 deletions app/contracts/queries/payments_query_filters_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Queries
class PaymentsQueryFiltersContract < Dry::Validation::Contract
UUID_REGEX = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/

params do
required(:filters).hash do
optional(:invoice_id).maybe(:string, format?: UUID_REGEX)
optional(:external_customer_id).maybe(:string)
end
end
end
end
1 change: 1 addition & 0 deletions app/controllers/api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def index
payment_status: (params[:payment_status] if valid_payment_status?(params[:payment_status])),
payment_dispute_lost: params[:payment_dispute_lost],
payment_overdue: (params[:payment_overdue] if %w[true false].include?(params[:payment_overdue])),
partially_paid: (params[:partially_paid] if %w[true false].include?(params[:partially_paid])),
status: (params[:status] if valid_status?(params[:status])),
currency: params[:currency],
customer_external_id: params[:external_customer_id],
Expand Down
81 changes: 81 additions & 0 deletions app/controllers/api/v1/payments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

module Api
module V1
class PaymentsController < Api::BaseController
def create
result = ManualPayments::CreateService.call(
organization: current_organization,
params: create_params.to_h.deep_symbolize_keys
)

if result.success?
render(
json: ::V1::PaymentSerializer.new(result.payment, root_name: resource_name)
)
else
render_error_response(result)
end
end

def index
result = PaymentsQuery.call(
organization: current_organization,
pagination: {
page: params[:page],
limit: params[:per_page] || PER_PAGE
},
filters: index_filters
)

if result.success?
render(
json: ::CollectionSerializer.new(
result.payments,
::V1::PaymentSerializer,
collection_name: resource_name.pluralize,
meta: pagination_metadata(result.payments)
)
)
else
render_error_response(result)
end
end

def show
payment = Payment.for_organization(current_organization).find_by(id: params[:id])
return not_found_error(resource: resource_name) unless payment

render_payment(payment)
end

private

def create_params
params.require(:payment).permit(
:invoice_id,
:amount_cents,
:reference,
:paid_at
)
end

def index_filters
params.permit(:invoice_id, :external_customer_id)
end

def render_payment(payment)
render(
json: ::V1::PaymentSerializer.new(
payment,
root_name: resource_name
)
)
end

def resource_name
'payment'
end
end
end
end
2 changes: 1 addition & 1 deletion app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class ApiKey < ApplicationRecord

RESOURCES = %w[
add_on analytic billable_metric coupon applied_coupon credit_note customer_usage
customer event fee invoice organization payment_request plan subscription lifetime_usage
customer event fee invoice organization payment payment_request plan subscription lifetime_usage
tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section
].freeze

Expand Down
28 changes: 22 additions & 6 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,35 @@ class Payment < ApplicationRecord
has_many :refunds
has_many :integration_resources, as: :syncable

PAYMENT_TYPES = {provider: "provider", manual: "manual"}
PAYMENT_TYPES = {provider: 'provider', manual: 'manual'}.freeze
attribute :payment_type, :string
enum :payment_type, PAYMENT_TYPES, default: :provider, prefix: :payment_type
validates :payment_type, presence: true
validates :reference, presence: true, length: {maximum: 40}, if: -> { payment_type_manual? }
validates :reference, absence: true, if: -> { payment_type_provider? }
validate :max_invoice_paid_amount_cents, on: :create
validate :payment_request_succeeded, on: :create

delegate :customer, to: :payable

enum payable_payment_status: PAYABLE_PAYMENT_STATUS.map { |s| [s, s] }.to_h

validate :max_invoice_paid_amount_cents, on: :create
validate :payment_request_succeeded, on: :create
scope :for_organization, lambda { |organization|
payables_join = ActiveRecord::Base.sanitize_sql_array([
<<~SQL,
LEFT JOIN invoices
ON invoices.id = payments.payable_id
AND payments.payable_type = 'Invoice'
AND invoices.organization_id = :org_id
LEFT JOIN payment_requests
ON payment_requests.id = payments.payable_id
AND payments.payable_type = 'PaymentRequest'
AND payment_requests.organization_id = :org_id
SQL
{org_id: organization.id}
])
joins(payables_join).where('invoices.id IS NOT NULL OR payment_requests.id IS NOT NULL')
}

def should_sync_payment?
return false unless payable.is_a?(Invoice)
Expand All @@ -44,9 +60,9 @@ def max_invoice_paid_amount_cents
def payment_request_succeeded
return if !payable.is_a?(Invoice) || payment_type_provider?

if payable.payment_requests.where(payment_status: 'succeeded').exists?
errors.add(:base, :payment_request_is_already_succeeded)
end
return unless payable.payment_requests.where(payment_status: 'succeeded').exists?

errors.add(:base, :payment_request_is_already_succeeded)
end
end

Expand Down
11 changes: 11 additions & 0 deletions app/queries/invoices_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def call
invoices = with_payment_overdue(invoices) unless filters.payment_overdue.nil?
invoices = with_amount_range(invoices) if filters.amount_from.present? || filters.amount_to.present?
invoices = with_metadata(invoices) if filters.metadata.present?
invoices = with_partially_paid(invoices) unless filters.partially_paid.nil?

result.invoices = invoices
result
Expand Down Expand Up @@ -88,6 +89,16 @@ def with_payment_overdue(scope)
scope.where(payment_overdue: filters.payment_overdue)
end

def with_partially_paid(scope)
partially_paid = ActiveModel::Type::Boolean.new.cast(filters.partially_paid)

if partially_paid
scope.where("total_amount_cents > total_paid_amount_cents AND total_paid_amount_cents > 0")
else
scope.where("total_amount_cents = total_paid_amount_cents OR total_paid_amount_cents = 0")
end
end

def with_issuing_date_range(scope)
scope = scope.where(issuing_date: issuing_date_from..) if filters.issuing_date_from
scope = scope.where(issuing_date: ..issuing_date_to) if filters.issuing_date_to
Expand Down
51 changes: 51 additions & 0 deletions app/queries/payments_query.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

class PaymentsQuery < BaseQuery
def call
return result unless validate_filters.success?

payments = apply_filters(base_scope)
payments = paginate(payments)
payments = apply_consistent_ordering(payments)

result.payments = payments
result
end

private

def filters_contract
@filters_contract ||= Queries::PaymentsQueryFiltersContract.new
end

def base_scope
Payment.for_organization(organization)
end

def apply_filters(scope)
scope = filter_by_invoice(scope) if filters.invoice_id.present?
scope = filter_by_customer(scope) if filters.external_customer_id.present?
scope
end

def filter_by_customer(scope)
external_customer_id = filters.external_customer_id

scope.joins(<<~SQL)
LEFT JOIN customers ON
(payments.payable_type = 'Invoice' AND customers.id = invoices.customer_id) OR
(payments.payable_type = 'PaymentRequest' AND customers.id = payment_requests.customer_id)
SQL
.where('customers.external_id = :external_customer_id', external_customer_id:)
end

def filter_by_invoice(scope)
invoice_id = filters.invoice_id

scope.joins(<<~SQL)
LEFT JOIN invoices_payment_requests
ON invoices_payment_requests.payment_request_id = payment_requests.id
SQL
.where('invoices.id = :invoice_id OR invoices_payment_requests.invoice_id = :invoice_id', invoice_id:)
end
end
6 changes: 2 additions & 4 deletions app/serializers/v1/payment_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ class PaymentSerializer < ModelSerializer
def serialize
{
lago_id: model.id,
invoice_id: invoice_id,
invoice_ids: invoice_id,
amount_cents: model.amount_cents,
amount_currency: model.amount_currency,
payment_status: model.payable_payment_status,
type: model.payment_type,
reference: model.reference,
payment_provider_id: model.payment_provider_id,
payment_provider_customer_id: model.payment_provider_customer_id,
external_payment_id: model.provider_payment_id,
created_at: model.created_at.iso8601
}
Expand All @@ -21,7 +19,7 @@ def serialize
private

def invoice_id
model.payable.is_a?(Invoice) ? model.payable.id : model.payable.invoice_ids
model.payable.is_a?(Invoice) ? [model.payable.id] : model.payable.invoice_ids
end
end
end
13 changes: 9 additions & 4 deletions app/services/manual_payments/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

module ManualPayments
class CreateService < BaseService
def initialize(invoice:, params:)
@invoice = invoice
def initialize(organization:, params:)
@organization = organization
@params = params

super
Expand All @@ -22,7 +22,8 @@ def call
amount_currency: invoice.currency,
status: 'succeeded',
payable_payment_status: 'succeeded',
payment_type: :manual
payment_type: :manual,
created_at: params[:paid_at].present? ? Time.iso8601(params[:paid_at]) : nil
)

invoice.update!(total_paid_amount_cents: invoice.total_paid_amount_cents + amount_cents)
Expand All @@ -43,7 +44,11 @@ def call

private

attr_reader :invoice, :params
attr_reader :organization, :params

def invoice
@invoice ||= organization.invoices.find_by(id: params[:invoice_id])
end

def check_preconditions
return result.forbidden_failure! unless License.premium?
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
put :sync_salesforce_id, on: :member
end
resources :payment_requests, only: %i[create index]
resources :payments, only: %i[create index show]
resources :plans, param: :code, code: /.*/
resources :taxes, param: :code, code: /.*/
resources :wallet_transactions, only: :create
Expand Down
85 changes: 85 additions & 0 deletions spec/contracts/queries/payments_query_filters_contract_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Queries::PaymentsQueryFiltersContract, type: :contract do
subject(:result) { described_class.new.call(filters:) }

let(:filters) { {} }

context "when filters are valid" do
context "when invoice_id is valid" do
let(:filters) { {invoice_id: "7b199d93-2663-4e68-beca-203aefcd019b"} }

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when invoice_id is blank" do
let(:filters) { {invoice_id: nil} }

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when external_customer_id is valid" do
let(:filters) { {external_customer_id: "valid_string"} }

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when external_customer_id is blank" do
let(:filters) { {external_customer_id: nil} }

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when both invoice_id and external_customer_id are valid" do
let(:filters) { {invoice_id: "7b199d93-2663-4e68-beca-203aefcd019b", external_customer_id: "valid_string"} }

it "is valid" do
expect(result.success?).to be(true)
end
end
end

context "when filters are invalid" do
context "when invoice_id is not a UUID" do
let(:filters) { {invoice_id: "invalid_uuid"} }

it "is invalid" do
expect(result.success?).to be(false)
expect(result.errors.to_h).to include(filters: {invoice_id: ["is in invalid format"]})
end
end

context "when external_customer_id is not a string" do
let(:filters) { {external_customer_id: 123} }

it "is invalid" do
expect(result.success?).to be(false)
expect(result.errors.to_h).to include(filters: {external_customer_id: ["must be a string"]})
end
end

context "when both invoice_id and external_customer_id are invalid" do
let(:filters) { {invoice_id: "invalid_uuid", external_customer_id: 123} }

it "is invalid" do
expect(result.success?).to be(false)
expect(result.errors.to_h).to include(
filters: {
invoice_id: ["is in invalid format"],
external_customer_id: ["must be a string"]
}
)
end
end
end
end
Loading

0 comments on commit b28b40d

Please sign in to comment.