Skip to content

Commit c228fbc

Browse files
feat(metadata): Services, GraphQL and API layers (#4663)
## Roadmap Task - [SPEC: Metadata on credit notes](https://www.notion.so/getlago/Spec-Metadata-on-credit-notes-2afef63110d2805cbbfceb3dfe754940) - [BE: Metadata on credit notes](https://www.notion.so/getlago/BE-Metadata-on-credit-notes-2b0ef63110d280d49217d215b566d688) - [Canny request](https://getlago.canny.io/feature-requests/p/add-metadata-to-more-objects) ## Context This PR adds Services, GraphQL and REST API support for credit note metadata. These are parts 2-4 of the feature ## Description ### Read operations - **GET /v1/credit_notes/:id** — returns metadata in the response - **GET /v1/credit_notes** — includes metadata with eager loading (also fixes an existing N+1 query) ### Write operations via credit note endpoints - **POST /v1/credit_notes** — accepts a `metadata` hash on creation - **PUT /v1/credit_notes/:id** — supports metadata merge and replace ### Dedicated metadata endpoints | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/v1/credit_notes/:id/metadata` | Replaces all metadata | | PATCH | `/v1/credit_notes/:id/metadata` | Merges new keys into existing metadata | | DELETE | `/v1/credit_notes/:id/metadata` | Removes all metadata | | DELETE | `/v1/credit_notes/:id/metadata/:key` | Removes a single key | --------- Co-authored-by: Julien Bourdeau <julien@julienbourdeau.com>
1 parent c451fa9 commit c228fbc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2044
-75
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
module CreditNotes
6+
class BaseController < Api::BaseController
7+
before_action :find_credit_note
8+
9+
private
10+
11+
attr_reader :credit_note
12+
13+
def find_credit_note
14+
@credit_note = current_organization.credit_notes.finalized.find_by!(id: params[:credit_note_id])
15+
rescue ActiveRecord::RecordNotFound
16+
not_found_error(resource: "credit_note")
17+
end
18+
19+
def resource_name
20+
"credit_note"
21+
end
22+
end
23+
end
24+
end
25+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
module CreditNotes
6+
class MetadataController < BaseController
7+
def create
8+
result = ::CreditNotes::UpdateService.call(credit_note:, metadata:)
9+
10+
if result.success?
11+
render_metadata
12+
else
13+
render_error_response(result)
14+
end
15+
end
16+
17+
def update
18+
result = ::CreditNotes::UpdateService.call(credit_note:, partial_metadata: true, metadata:)
19+
20+
if result.success?
21+
render_metadata
22+
else
23+
render_error_response(result)
24+
end
25+
end
26+
27+
def destroy
28+
result = ::CreditNotes::UpdateService.call(credit_note:, metadata: nil)
29+
30+
if result.success?
31+
render_metadata
32+
else
33+
render_error_response(result)
34+
end
35+
end
36+
37+
def destroy_key
38+
return not_found_error(resource: "metadata") unless credit_note.metadata
39+
40+
result = Metadata::DeleteItemKeyService.call(item: credit_note.metadata, key: params[:key])
41+
42+
if result.success?
43+
render_metadata
44+
else
45+
render_error_response(result)
46+
end
47+
end
48+
49+
private
50+
51+
def metadata
52+
params.fetch(:metadata, {}).permit!.to_h
53+
end
54+
55+
def render_metadata
56+
render(json: {metadata: credit_note.reload.metadata&.value})
57+
end
58+
end
59+
end
60+
end
61+
end

app/controllers/api/v1/credit_notes_controller.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module V1
55
class CreditNotesController < Api::BaseController
66
include CreditNoteIndex
77
def create
8-
result = CreditNotes::CreateService.call(
8+
result = ::CreditNotes::CreateService.call(
99
invoice: current_organization.invoices.visible.find_by(id: input_params[:invoice_id]),
1010
**input_params
1111
)
@@ -40,12 +40,12 @@ def update
4040
credit_note = current_organization.credit_notes.find_by(id: params[:id])
4141
return not_found_error(resource: "credit_note") unless credit_note
4242

43-
result = CreditNotes::UpdateService.new(credit_note:, **update_params).call
43+
result = ::CreditNotes::UpdateService.new(credit_note:, partial_metadata: true, **update_params).call
4444

4545
if result.success?
4646
render(
4747
json: ::V1::CreditNoteSerializer.new(
48-
credit_note,
48+
result.credit_note,
4949
root_name: "credit_note",
5050
includes: %i[items applied_taxes]
5151
)
@@ -68,7 +68,7 @@ def download_pdf
6868
)
6969
end
7070

71-
CreditNotes::GeneratePdfJob.perform_later(credit_note)
71+
::CreditNotes::GeneratePdfJob.perform_later(credit_note)
7272

7373
head(:ok)
7474
end
@@ -86,7 +86,7 @@ def download_xml
8686
)
8787
end
8888

89-
CreditNotes::GenerateXmlJob.perform_later(credit_note)
89+
::CreditNotes::GenerateXmlJob.perform_later(credit_note)
9090

9191
head(:ok)
9292
end
@@ -95,7 +95,7 @@ def void
9595
credit_note = current_organization.credit_notes.find_by(id: params[:id])
9696
return not_found_error(resource: "credit_note") unless credit_note
9797

98-
result = CreditNotes::VoidService.new(credit_note:).call
98+
result = ::CreditNotes::VoidService.new(credit_note:).call
9999

100100
if result.success?
101101
render(
@@ -118,7 +118,7 @@ def index
118118
end
119119

120120
def estimate
121-
result = CreditNotes::EstimateService.call(
121+
result = ::CreditNotes::EstimateService.call(
122122
invoice: current_organization.invoices.visible.find_by(id: estimate_params[:invoice_id]),
123123
items: estimate_params[:items]
124124
)
@@ -145,6 +145,7 @@ def input_params
145145
:description,
146146
:credit_amount_cents,
147147
:refund_amount_cents,
148+
metadata: {},
148149
items: [
149150
:fee_id,
150151
:amount_cents
@@ -153,7 +154,7 @@ def input_params
153154
end
154155

155156
def update_params
156-
params.require(:credit_note).permit(:refund_status)
157+
params.require(:credit_note).permit(:refund_status, metadata: {})
157158
end
158159

159160
def estimate_params

app/controllers/concerns/credit_note_index.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ def credit_note_index(external_customer_id:)
3434
if result.success?
3535
render(
3636
json: ::CollectionSerializer.new(
37-
result.credit_notes.includes(:items, :applied_taxes, :invoice),
37+
result.credit_notes.includes(
38+
:items,
39+
:applied_taxes,
40+
:file_attachment,
41+
:xml_file_attachment,
42+
:error_details,
43+
:metadata,
44+
invoice: :billing_entity
45+
),
3846
::V1::CreditNoteSerializer,
3947
collection_name: "credit_notes",
4048
meta: pagination_metadata(result.credit_notes),

app/graphql/mutations/credit_notes/create.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Create < BaseMutation
1919
argument :refund_amount_cents, GraphQL::Types::BigInt, required: false
2020

2121
argument :items, [Types::CreditNoteItems::Input], required: true
22+
argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS
2223

2324
type Types::CreditNotes::Object
2425

app/graphql/mutations/credit_notes/update.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ class Update < BaseMutation
1010
graphql_name "UpdateCreditNote"
1111
description "Updates an existing Credit Note"
1212

13-
argument :id, ID, required: true
14-
argument :refund_status, Types::CreditNotes::RefundStatusTypeEnum, required: true
13+
input_object_class Types::CreditNotes::UpdateCreditNoteInput
1514

1615
type Types::CreditNotes::Object
1716

1817
def resolve(**args)
1918
result = ::CreditNotes::UpdateService.new(
2019
credit_note: context[:current_user].credit_notes.find_by(id: args[:id]),
21-
refund_status: args[:refund_status]
20+
refund_status: args[:refund_status],
21+
metadata: args[:metadata]
2222
).call
2323

2424
result.success? ? result.credit_note : result_error(result)

app/graphql/resolvers/credit_notes_resolver.rb

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,32 +30,21 @@ class CreditNotesResolver < Resolvers::BaseResolver
3030

3131
type Types::CreditNotes::Object.collection_type, null: false
3232

33-
def resolve(args)
34-
result = CreditNotesQuery.call(
33+
FILTER_KEYS = %i[
34+
amount_from amount_to billing_entity_ids credit_status currency customer_external_id
35+
customer_id invoice_number issuing_date_from issuing_date_to reason refund_status self_billed
36+
].freeze
37+
38+
def resolve(**args)
39+
includes = [:customer, :items]
40+
41+
CreditNotesQuery.call(
3542
organization: current_organization,
3643
search_term: args[:search_term],
37-
filters: {
38-
amount_from: args[:amount_from],
39-
amount_to: args[:amount_to],
40-
billing_entity_ids: args[:billing_entity_ids],
41-
credit_status: args[:credit_status],
42-
currency: args[:currency],
43-
customer_external_id: args[:customer_external_id],
44-
customer_id: args[:customer_id],
45-
invoice_number: args[:invoice_number],
46-
issuing_date_from: args[:issuing_date_from],
47-
issuing_date_to: args[:issuing_date_to],
48-
reason: args[:reason],
49-
refund_status: args[:refund_status],
50-
self_billed: args[:self_billed]
51-
},
52-
pagination: {
53-
page: args[:page],
54-
limit: args[:limit]
55-
}
56-
)
57-
58-
result.credit_notes
44+
includes:,
45+
filters: args.slice(*FILTER_KEYS),
46+
pagination: args.slice(:page, :limit)
47+
).credit_notes
5948
end
6049
end
6150
end

app/graphql/types/credit_notes/object.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,14 @@ class Object < Types::BaseObject
5151
field :error_details, [Types::ErrorDetails::Object], null: true
5252
field :external_integration_id, String, null: true
5353
field :integration_syncable, GraphQL::Types::Boolean, null: false
54+
field :metadata, [Types::Metadata::Object], null: true
5455
field :tax_provider_id, String, null: true
5556
field :tax_provider_syncable, GraphQL::Types::Boolean, null: false
5657

58+
def metadata
59+
object.metadata&.value&.map { |key, value| {key:, value:} }
60+
end
61+
5762
def applied_taxes
5863
object.applied_taxes.order(tax_rate: :desc)
5964
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module Types
4+
module CreditNotes
5+
class UpdateCreditNoteInput < BaseInputObject
6+
description "Update Credit Note input arguments"
7+
8+
argument :id, ID, required: true
9+
argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS
10+
argument :refund_status, Types::CreditNotes::RefundStatusTypeEnum, required: false
11+
end
12+
end
13+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module Types
4+
module Metadata
5+
class Input < Types::BaseInputObject
6+
graphql_name "MetadataInput"
7+
description "Input for metadata key-value pair"
8+
9+
argument :key, String, required: true
10+
argument :value, String, required: :nullable
11+
12+
ARGUMENT_OPTIONS = {
13+
prepare: ->(value, _ctx) { value&.reduce({}) { |h, item| h.merge(item[:key] => item[:value]) } },
14+
validates: {::Validators::UniqueByFieldValidator => {field_name: :key}}
15+
}.freeze
16+
end
17+
end
18+
end

0 commit comments

Comments
 (0)