diff --git a/.rubocop.yml b/.rubocop.yml index 88e8b5440817..08a7e3035b88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -168,6 +168,10 @@ RSpec/MultipleMemoizedHelpers: RSpec/NestedGroups: Enabled: false +RSpec/FilePath: + Exclude: + - 'spec/requests/**/*' + Style/BlockDelimiters: Description: >- Avoid using {...} for multi-line blocks (multiline chaining is always ugly). diff --git a/app/controllers/api/v1/credit_notes_controller.rb b/app/controllers/api/v1/credit_notes_controller.rb new file mode 100644 index 000000000000..492b97118266 --- /dev/null +++ b/app/controllers/api/v1/credit_notes_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Api + module V1 + class CreditNotesController < Api::BaseController + def show + credit_note = current_organization.credit_notes.find_by(id: params[:id]) + return not_found_error(resource: 'credit_note') unless credit_note + + render( + json: ::V1::CreditNoteSerializer.new( + credit_note, + root_name: 'credit_note', + includes: %i[fees], + ), + ) + end + + def index + credit_notes = current_organization.credit_notes + + if params[:external_customer_id] + credit_notes = credit_notes.joins(:customer).where(customers: { external_id: params[:external_customer_id] }) + end + + credit_notes = credit_notes.order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + credit_notes, + ::V1::CreditNoteSerializer, + collection_name: 'credit_notes', + meta: pagination_metadata(credit_notes), + ), + ) + end + end + end +end diff --git a/app/models/customer.rb b/app/models/customer.rb index d626de856b76..c3a6e67ce0d4 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -13,6 +13,7 @@ class Customer < ApplicationRecord has_many :invoices has_many :applied_coupons has_many :coupons, through: :applied_coupons + has_many :credit_notes has_many :applied_add_ons has_many :add_ons, through: :applied_add_ons has_many :wallets diff --git a/app/serializers/v1/credit_note_serializer.rb b/app/serializers/v1/credit_note_serializer.rb new file mode 100644 index 000000000000..6ce21921bdf6 --- /dev/null +++ b/app/serializers/v1/credit_note_serializer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module V1 + class CreditNoteSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + sequential_id: model.sequential_id, + number: model.number, + lago_invoice_id: model.invoice_id, + invoice_number: model.invoice.number, + status: model.status, + reason: model.reason, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + remaining_amount_cents: model.remaining_amount_cents, + remaining_amount_currency: model.remaining_amount_currency, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601, + file_url: nil, # TODO: Expose credit note document in API + } + + payload = payload.merge(fees) if include?(:fees) + + payload + end + + private + + def fees + ::CollectionSerializer.new(model.fees, ::V1::FeeSerializer, collection_name: 'fees').serialize + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 8bb8cb37e13e..d385bcde8a93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,7 @@ resources :add_ons, param: :code resources :billable_metrics, param: :code resources :coupons, param: :code + resources :credit_notes, only: %i[show index] resources :events, only: %i[create show] resources :applied_coupons, only: %i[create] resources :applied_add_ons, only: %i[create] diff --git a/spec/requests/api/v1/credit_notes_spec.rb b/spec/requests/api/v1/credit_notes_spec.rb new file mode 100644 index 000000000000..7f3f30fe1f1c --- /dev/null +++ b/spec/requests/api/v1/credit_notes_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::CreditNotesController, type: :request do + let(:invoice) { create(:invoice) } + let(:organization) { invoice.organization } + let(:customer) { invoice.customer } + let(:credit_note) { create(:credit_note, invoice: invoice, customer: customer) } + let(:credit_note_items) { create_list(:credit_note_item, 2, credit_note: credit_note) } + + describe 'GET /credit_notes/:id' do + before { credit_note_items } + + it 'returns a credit note' do + get_with_token(organization, "/api/v1/credit_notes/#{credit_note.id}") + + aggregate_failures do + expect(response).to have_http_status(:success) + expect(json[:credit_note][:lago_id]).to eq(credit_note.id) + expect(json[:credit_note][:sequential_id]).to eq(credit_note.sequential_id) + expect(json[:credit_note][:number]).to eq(credit_note.number) + expect(json[:credit_note][:lago_invoice_id]).to eq(invoice.id) + expect(json[:credit_note][:invoice_number]).to eq(invoice.number) + expect(json[:credit_note][:status]).to eq(credit_note.status) + expect(json[:credit_note][:reason]).to eq(credit_note.reason) + expect(json[:credit_note][:amount_cents]).to eq(credit_note.amount_cents) + expect(json[:credit_note][:amount_currency]).to eq(credit_note.amount_currency) + expect(json[:credit_note][:remaining_amount_cents]).to eq(credit_note.remaining_amount_cents) + expect(json[:credit_note][:remaining_amount_currency]).to eq(credit_note.remaining_amount_currency) + expect(json[:credit_note][:created_at]).to eq(credit_note.created_at.iso8601) + expect(json[:credit_note][:updated_at]).to eq(credit_note.updated_at.iso8601) + + expect(json[:credit_note][:fees].count).to eq(2) + end + end + + context 'when credit note does not exists' do + it 'returns not found' do + get_with_token(organization, '/api/v1/credit_notes/foo') + + expect(response).to have_http_status(:not_found) + end + end + + context 'when credit note belongs to another organization' do + let(:wrong_credit_note) { create(:credit_note) } + + it 'returns not found' do + get_with_token(organization, "/api/v1/credit_notes/#{wrong_credit_note.id}") + end + end + end + + describe 'GET /credits_notes' do + let(:second_customer) { create(:customer, organization: organization) } + let(:second_invoice) { create(:invoice, customer: second_customer) } + let(:second_credit_note) { create(:credit_note, invoice: second_invoice, customer: second_invoice.customer) } + + before do + credit_note + second_credit_note + end + + it 'returns a list of credit_notes' do + get_with_token(organization, '/api/v1/credit_notes') + + aggregate_failures do + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(2) + expect(json[:credit_notes].first[:lago_id]).to eq(second_credit_note.id) + expect(json[:credit_notes].last[:lago_id]).to eq(credit_note.id) + end + end + + context 'with pagination' do + it 'returns the metadata' do + get_with_token(organization, '/api/v1/credit_notes?page=1&per_page=1') + + aggregate_failures do + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(1) + + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end + + context 'with external_customer_id filter' do + it 'returns credit notes of the customer' do + get_with_token(organization, "/api/v1/credit_notes?external_customer_id=#{customer.external_id}") + + aggregate_failures do + expect(response).to have_http_status(:success) + + expect(json[:credit_notes].count).to eq(1) + expect(json[:credit_notes].first[:lago_id]).to eq(credit_note.id) + end + end + end + end +end