From 05864a3a4bd8f47d294357379c89c0fcfea4ee48 Mon Sep 17 00:00:00 2001 From: Anna Velentsevich Date: Tue, 14 Jan 2025 09:40:12 +0100 Subject: [PATCH] Feat(invoice_custom_sections): add create_invoice_applied_custom_sections service (#3008) ## Description Add a service to create applied invoice custom sections for invoices --- app/controllers/api/v1/invoices_controller.rb | 2 +- .../invoice_custom_sections_resolver.rb | 11 ++-- app/models/customer.rb | 2 +- app/models/invoice_custom_section.rb | 4 +- .../v1/invoice_custom_section_serializer.rb | 6 +- app/serializers/v1/invoice_serializer.rb | 9 +++ ...plied_invoice_custom_section_serializer.rb | 18 ++++++ .../manage_invoice_custom_sections_service.rb | 4 +- app/services/invoices/add_on_service.rb | 1 + .../invoices/advance_charges_service.rb | 1 + .../apply_invoice_custom_sections_service.rb | 34 +++++++++++ .../invoices/create_one_off_service.rb | 1 + .../create_pay_in_advance_charge_service.rb | 1 + app/services/invoices/paid_credit_service.rb | 1 + .../invoices/progressive_billing_service.rb | 1 + .../invoices/refresh_draft_service.rb | 3 + app/services/invoices/subscription_service.rb | 1 + .../invoices/add_on_created_service.rb | 2 +- .../webhooks/invoices/created_service.rb | 2 +- .../invoices/one_off_created_service.rb | 2 +- .../invoices/paid_credit_added_service.rb | 2 +- app/views/templates/invoices/v3.slim | 3 + .../invoices/v3/_custom_sections.slim | 15 +++++ app/views/templates/invoices/v3/charge.slim | 3 + app/views/templates/invoices/v3/one_off.slim | 3 + app/views/templates/invoices/v4.slim | 18 ++++-- .../invoices/v4/_custom_sections.slim | 15 +++++ app/views/templates/invoices/v4/charge.slim | 3 + app/views/templates/invoices/v4/one_off.slim | 3 + .../invoice_custom_sections_resolver_spec.rb | 3 +- spec/models/customer_spec.rb | 4 +- .../api/v1/customers_controller_spec.rb | 2 +- .../api/v1/invoices_controller_spec.rb | 3 +- .../invoice_custom_section_serializer_spec.rb | 6 +- ..._invoice_custom_section_serializer_spec.rb | 32 +++++++++++ spec/services/invoices/add_on_service_spec.rb | 4 ++ ...ly_invoice_custom_sections_service_spec.rb | 57 +++++++++++++++++++ .../invoices/create_one_off_service_spec.rb | 4 ++ ...eate_pay_in_advance_charge_service_spec.rb | 4 ++ .../invoices/paid_credit_service_spec.rb | 4 ++ .../progressive_billing_service_spec.rb | 4 ++ ...refresh_draft_and_finalize_service_spec.rb | 4 ++ .../invoices/refresh_draft_service_spec.rb | 19 +++++++ .../invoices/subscription_service_spec.rb | 4 ++ .../applied_invoice_custom_sections.rb | 25 ++++++++ 45 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb create mode 100644 app/services/invoices/apply_invoice_custom_sections_service.rb create mode 100644 app/views/templates/invoices/v3/_custom_sections.slim create mode 100644 app/views/templates/invoices/v4/_custom_sections.slim create mode 100644 spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb create mode 100644 spec/services/invoices/apply_invoice_custom_sections_service_spec.rb diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb index a203051cdd3..5be51b7b6c7 100644 --- a/app/controllers/api/v1/invoices_controller.rb +++ b/app/controllers/api/v1/invoices_controller.rb @@ -241,7 +241,7 @@ def render_invoice(invoice) json: ::V1::InvoiceSerializer.new( invoice, root_name: 'invoice', - includes: %i[customer subscriptions fees credits metadata applied_taxes error_details] + includes: %i[customer subscriptions fees credits metadata applied_taxes error_details applied_invoice_custom_sections] ) ) end diff --git a/app/graphql/resolvers/invoice_custom_sections_resolver.rb b/app/graphql/resolvers/invoice_custom_sections_resolver.rb index f34d3fc1c43..f759479ab7f 100644 --- a/app/graphql/resolvers/invoice_custom_sections_resolver.rb +++ b/app/graphql/resolvers/invoice_custom_sections_resolver.rb @@ -15,10 +15,13 @@ class InvoiceCustomSectionsResolver < Resolvers::BaseResolver type Types::InvoiceCustomSections::Object.collection_type, null: true def resolve(page: nil, limit: nil) - current_organization.invoice_custom_sections.left_outer_joins(:invoice_custom_section_selections).order( - Arel.sql('CASE WHEN invoice_custom_section_selections.id IS NOT NULL THEN 0 ELSE 1 END'), - :name - ).page(page).per(limit) + current_organization.invoice_custom_sections + .joins('LEFT JOIN invoice_custom_section_selections ON invoice_custom_sections.id = invoice_custom_section_selections.invoice_custom_section_id + AND invoice_custom_section_selections.customer_id is NULL') + .order( + Arel.sql('CASE WHEN invoice_custom_section_selections.id IS NOT NULL THEN 0 ELSE 1 END'), + :name + ).page(page).per(limit) end end end diff --git a/app/models/customer.rb b/app/models/customer.rb index 25aea92262a..91015b26df4 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -134,7 +134,7 @@ def applicable_net_payment_term def applicable_invoice_custom_sections return [] if skip_invoice_custom_sections? - selected_invoice_custom_sections.presence || organization.selected_invoice_custom_sections + selected_invoice_custom_sections.order(:name).presence || organization.selected_invoice_custom_sections.order(:name) end def editable? diff --git a/app/models/invoice_custom_section.rb b/app/models/invoice_custom_section.rb index 4842aaed333..bd049ead9cf 100644 --- a/app/models/invoice_custom_section.rb +++ b/app/models/invoice_custom_section.rb @@ -8,7 +8,9 @@ class InvoiceCustomSection < ApplicationRecord has_many :invoice_custom_section_selections, dependent: :destroy validates :name, presence: true - validates :code, presence: true, uniqueness: {scope: :organization_id} + validates :code, + presence: true, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} default_scope -> { kept } diff --git a/app/serializers/v1/invoice_custom_section_serializer.rb b/app/serializers/v1/invoice_custom_section_serializer.rb index 87dd9b6e914..f430be28a5c 100644 --- a/app/serializers/v1/invoice_custom_section_serializer.rb +++ b/app/serializers/v1/invoice_custom_section_serializer.rb @@ -10,10 +10,8 @@ def serialize description: model.description, details: model.details, display_name: model.display_name, - selected_for_organization: model.selected_for_organization?, - organization: { - lago_id: model.organization_id - } + applied_to_organization: model.selected_for_organization?, + organization_id: model.organization_id } end end diff --git a/app/serializers/v1/invoice_serializer.rb b/app/serializers/v1/invoice_serializer.rb index 522f94ebd81..ef1169162e3 100644 --- a/app/serializers/v1/invoice_serializer.rb +++ b/app/serializers/v1/invoice_serializer.rb @@ -39,6 +39,7 @@ def serialize payload.merge!(applied_taxes) if include?(:applied_taxes) payload.merge!(error_details) if include?(:error_details) payload.merge!(applied_usage_thresholds) if model.progressive_billing? + payload.merge!(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) payload end @@ -112,5 +113,13 @@ def applied_usage_thresholds collection_name: 'applied_usage_thresholds' ).serialize end + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::Invoices::AppliedInvoiceCustomSectionSerializer, + collection_name: 'applied_invoice_custom_sections' + ).serialize + end end end diff --git a/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb b/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb new file mode 100644 index 00000000000..7894892b62b --- /dev/null +++ b/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class AppliedInvoiceCustomSectionSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_invoice_id: model.invoice_id, + code: model.code, + details: model.details, + display_name: model.display_name, + created_at: model.created_at.iso8601 + } + end + end + end +end diff --git a/app/services/customers/manage_invoice_custom_sections_service.rb b/app/services/customers/manage_invoice_custom_sections_service.rb index a7af90220ee..023e475bc6a 100644 --- a/app/services/customers/manage_invoice_custom_sections_service.rb +++ b/app/services/customers/manage_invoice_custom_sections_service.rb @@ -24,8 +24,8 @@ def call if !section_ids.nil? || !section_codes.nil? customer.skip_invoice_custom_sections = false - return result if customer.applicable_invoice_custom_sections.ids == section_ids || - customer.applicable_invoice_custom_sections.map(&:code) == section_codes + return result if customer.selected_invoice_custom_sections.ids == section_ids || + customer.selected_invoice_custom_sections.map(&:code) == section_codes assign_selected_sections end diff --git a/app/services/invoices/add_on_service.rb b/app/services/invoices/add_on_service.rb index 791665b2070..bc98bdd1110 100644 --- a/app/services/invoices/add_on_service.rb +++ b/app/services/invoices/add_on_service.rb @@ -26,6 +26,7 @@ def create create_add_on_fee(invoice) compute_amounts(invoice) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) invoice.save! diff --git a/app/services/invoices/advance_charges_service.rb b/app/services/invoices/advance_charges_service.rb index 159159ba036..a5ade782fde 100644 --- a/app/services/invoices/advance_charges_service.rb +++ b/app/services/invoices/advance_charges_service.rb @@ -69,6 +69,7 @@ def create_group_invoice end Invoices::ComputeAmountsFromFees.call(invoice:) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) invoice.payment_status = :succeeded Invoices::TransitionToFinalStatusService.call(invoice:) diff --git a/app/services/invoices/apply_invoice_custom_sections_service.rb b/app/services/invoices/apply_invoice_custom_sections_service.rb new file mode 100644 index 00000000000..f9349474a98 --- /dev/null +++ b/app/services/invoices/apply_invoice_custom_sections_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Invoices + class ApplyInvoiceCustomSectionsService < BaseService + def initialize(invoice:) + @invoice = invoice + @customer = invoice.customer + + super() + end + + def call + result.applied_sections = [] + return result if customer.skip_invoice_custom_sections + + customer.applicable_invoice_custom_sections.each do |custom_section| + invoice.applied_invoice_custom_sections.create!( + code: custom_section.code, + details: custom_section.details, + display_name: custom_section.display_name, + name: custom_section.name + ) + end + result.applied_sections = invoice.applied_invoice_custom_sections + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :customer + end +end diff --git a/app/services/invoices/create_one_off_service.rb b/app/services/invoices/create_one_off_service.rb index 44953c03332..b5719e92bdc 100644 --- a/app/services/invoices/create_one_off_service.rb +++ b/app/services/invoices/create_one_off_service.rb @@ -35,6 +35,7 @@ def call end Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes: result.fees_taxes) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded Invoices::TransitionToFinalStatusService.call(invoice:) invoice.save! diff --git a/app/services/invoices/create_pay_in_advance_charge_service.rb b/app/services/invoices/create_pay_in_advance_charge_service.rb index d78a6bdc0be..f002e8d7b5a 100644 --- a/app/services/invoices/create_pay_in_advance_charge_service.rb +++ b/app/services/invoices/create_pay_in_advance_charge_service.rb @@ -41,6 +41,7 @@ def call Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes: result.fees_taxes) create_credit_note_credit create_applied_prepaid_credit if should_create_applied_prepaid_credit? + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded Invoices::TransitionToFinalStatusService.call(invoice:) diff --git a/app/services/invoices/paid_credit_service.rb b/app/services/invoices/paid_credit_service.rb index 401d04ef29d..3bf445b9735 100644 --- a/app/services/invoices/paid_credit_service.rb +++ b/app/services/invoices/paid_credit_service.rb @@ -22,6 +22,7 @@ def call ActiveRecord::Base.transaction do create_credit_fee(invoice) compute_amounts(invoice) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) if License.premium? && wallet_transaction.invoice_requires_successful_payment? invoice.open! diff --git a/app/services/invoices/progressive_billing_service.rb b/app/services/invoices/progressive_billing_service.rb index 514b11a8f6c..e07ac6a039f 100644 --- a/app/services/invoices/progressive_billing_service.rb +++ b/app/services/invoices/progressive_billing_service.rb @@ -21,6 +21,7 @@ def call Credits::ProgressiveBillingService.call(invoice:) Credits::AppliedCouponsService.call(invoice:) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) return totals_result if !totals_result.success? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) diff --git a/app/services/invoices/refresh_draft_service.rb b/app/services/invoices/refresh_draft_service.rb index 5a815a387f3..4f61285dea6 100644 --- a/app/services/invoices/refresh_draft_service.rb +++ b/app/services/invoices/refresh_draft_service.rb @@ -56,6 +56,7 @@ def call recurring:, context: ) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) invoice.credit_notes.each do |credit_note| subscription_id = cn_subscription_ids.find { |h| h[:credit_note_id] == credit_note.id }[:subscription_id] @@ -121,6 +122,7 @@ def reset_invoice_values invoice_subscriptions.destroy_all invoice.applied_taxes.destroy_all invoice.error_details.discard_all + invoice.applied_invoice_custom_sections.destroy_all invoice.taxes_amount_cents = 0 invoice.total_amount_cents = 0 @@ -129,6 +131,7 @@ def reset_invoice_values invoice.sub_total_excluding_taxes_amount_cents = 0 invoice.sub_total_including_taxes_amount_cents = 0 invoice.progressive_billing_credit_amount_cents = 0 + invoice.save! end end diff --git a/app/services/invoices/subscription_service.rb b/app/services/invoices/subscription_service.rb index 2a5ce41ee3e..f4d20fd6abc 100644 --- a/app/services/invoices/subscription_service.rb +++ b/app/services/invoices/subscription_service.rb @@ -33,6 +33,7 @@ def call recurring:, context: ) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) set_invoice_generated_status unless invoice.pending? invoice.save! diff --git a/app/services/webhooks/invoices/add_on_created_service.rb b/app/services/webhooks/invoices/add_on_created_service.rb index 7ddffd3376c..92ddf958f13 100644 --- a/app/services/webhooks/invoices/add_on_created_service.rb +++ b/app/services/webhooks/invoices/add_on_created_service.rb @@ -13,7 +13,7 @@ def object_serializer ::V1::InvoiceSerializer.new( object, root_name: 'invoice', - includes: %i[customer subscriptions fees] + includes: %i[customer subscriptions fees applied_invoice_custom_sections] ) end diff --git a/app/services/webhooks/invoices/created_service.rb b/app/services/webhooks/invoices/created_service.rb index 8358922784b..48e395326a6 100644 --- a/app/services/webhooks/invoices/created_service.rb +++ b/app/services/webhooks/invoices/created_service.rb @@ -13,7 +13,7 @@ def object_serializer ::V1::InvoiceSerializer.new( object, root_name: 'invoice', - includes: %i[customer subscriptions fees credits applied_taxes] + includes: %i[customer subscriptions fees credits applied_taxes applied_invoice_custom_sections] ) end diff --git a/app/services/webhooks/invoices/one_off_created_service.rb b/app/services/webhooks/invoices/one_off_created_service.rb index 057fdddcd74..6090e5fb6c4 100644 --- a/app/services/webhooks/invoices/one_off_created_service.rb +++ b/app/services/webhooks/invoices/one_off_created_service.rb @@ -13,7 +13,7 @@ def object_serializer ::V1::InvoiceSerializer.new( object, root_name: 'invoice', - includes: %i[customer fees applied_taxes] + includes: %i[customer fees applied_taxes applied_invoice_custom_sections] ) end diff --git a/app/services/webhooks/invoices/paid_credit_added_service.rb b/app/services/webhooks/invoices/paid_credit_added_service.rb index 91e1ef83058..6956fc4476b 100644 --- a/app/services/webhooks/invoices/paid_credit_added_service.rb +++ b/app/services/webhooks/invoices/paid_credit_added_service.rb @@ -13,7 +13,7 @@ def object_serializer ::V1::InvoiceSerializer.new( object, root_name: 'invoice', - includes: %i[customer fees applied_taxes] + includes: %i[customer fees applied_taxes applied_invoice_custom_sections] ) end diff --git a/app/views/templates/invoices/v3.slim b/app/views/templates/invoices/v3.slim index 72a93e9eed1..44ef15c833c 100644 --- a/app/views/templates/invoices/v3.slim +++ b/app/views/templates/invoices/v3.slim @@ -425,6 +425,9 @@ html - else == SlimHelper.render('templates/invoices/v3/_subscriptions_summary', self) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/app/views/templates/invoices/v3/_custom_sections.slim b/app/views/templates/invoices/v3/_custom_sections.slim new file mode 100644 index 00000000000..d6e60631f3c --- /dev/null +++ b/app/views/templates/invoices/v3/_custom_sections.slim @@ -0,0 +1,15 @@ +css: + .invoice-custom-section { + margin-top: 24px; + border-bottom: 1px solid #D9DEE7; + } + + .invoice-custom-section p.section-name { + margin-bottom: 8px; + } + +.invoice-custom-sections.body-3.mb-24 + - applied_invoice_custom_sections.each do |section| + .invoice-custom-section + p.body-1.section-name = section.display_name + p.body-3.mb-24 = LineBreakHelper.break_lines(section.details) diff --git a/app/views/templates/invoices/v3/charge.slim b/app/views/templates/invoices/v3/charge.slim index a6f729c5f2e..d1d25b817bd 100644 --- a/app/views/templates/invoices/v3/charge.slim +++ b/app/views/templates/invoices/v3/charge.slim @@ -473,6 +473,9 @@ html td.body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.total_due') td.body-1 = MoneyHelper.format(total_amount) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/app/views/templates/invoices/v3/one_off.slim b/app/views/templates/invoices/v3/one_off.slim index c6ee18861ee..ea687005b4f 100644 --- a/app/views/templates/invoices/v3/one_off.slim +++ b/app/views/templates/invoices/v3/one_off.slim @@ -457,6 +457,9 @@ html td.body-1 = MoneyHelper.format(total_amount) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/app/views/templates/invoices/v4.slim b/app/views/templates/invoices/v4.slim index 4af9c015237..e71c74ae156 100644 --- a/app/views/templates/invoices/v4.slim +++ b/app/views/templates/invoices/v4.slim @@ -144,6 +144,9 @@ html /* ----------------------- variable ----------------------- */ + :root { + --border-color: #D9DEE7; + } @font-face { font-family: 'Inter var'; @@ -245,7 +248,7 @@ html color: #66758F; } .invoice-resume-table tr { - border-bottom: 1px solid #D9DEE7; + border-bottom: 1px solid var(--border-color); } .invoice-resume-table tr td { padding-top: 8px; @@ -284,12 +287,12 @@ html width: 50%; } .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { - border-bottom: 1px solid #D9DEE7; + border-bottom: 1px solid var(--border-color); text-align: left; width: 35%; } .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { - border-bottom: 1px solid #D9DEE7; + border-bottom: 1px solid var(--border-color); text-align: right; width: 15%; } @@ -320,10 +323,10 @@ html text-align: right; } .breakdown-details-table tr td { - border-bottom: 1px solid #d9dee7; + border-bottom: 1px solid var(--border-color); } .breakdown-details-table tr:first-child td { - border-top: 1px solid #d9dee7; + border-top: 1px solid var(--border-color); } .powered-by { @@ -365,7 +368,7 @@ html } .invoice-resume-table tr.details.subtotal td { padding-bottom: 8px; - border-bottom: 1px solid #d9dee7; + border-bottom: 1px solid var(--border-color); color: #19212e; } @@ -463,6 +466,9 @@ html - applied_usage_threshold = applied_usage_thresholds.order(created_at: :asc).last = I18n.t('invoice.reached_usage_threshold', usage_amount: MoneyHelper.format(applied_usage_threshold.lifetime_usage_amount), threshold_amount: MoneyHelper.format(applied_usage_threshold.passed_threshold_amount)) + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/app/views/templates/invoices/v4/_custom_sections.slim b/app/views/templates/invoices/v4/_custom_sections.slim new file mode 100644 index 00000000000..19381502b72 --- /dev/null +++ b/app/views/templates/invoices/v4/_custom_sections.slim @@ -0,0 +1,15 @@ +css: + .invoice-custom-section { + margin-top: 24px; + border-bottom: 1px solid #D9DEE7; + } + + .invoice-custom-section p.section-name { + margin-bottom: 8px; + } + +.invoice-custom-sections.body-3.mb-24 + - applied_invoice_custom_sections.each do |section| + .invoice-custom-section + p.body-1.section-name = section.display_name + p.body-3.mb-24 = LineBreakHelper.break_lines(section.details) diff --git a/app/views/templates/invoices/v4/charge.slim b/app/views/templates/invoices/v4/charge.slim index a3f5aeed39d..1ba78e6f734 100644 --- a/app/views/templates/invoices/v4/charge.slim +++ b/app/views/templates/invoices/v4/charge.slim @@ -560,6 +560,9 @@ html == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/app/views/templates/invoices/v4/one_off.slim b/app/views/templates/invoices/v4/one_off.slim index 287c66f89db..dd63a93aae8 100644 --- a/app/views/templates/invoices/v4/one_off.slim +++ b/app/views/templates/invoices/v4/one_off.slim @@ -465,6 +465,9 @@ html == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by span.body-2 diff --git a/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb b/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb index ee30a65aabd..4191f0a7525 100644 --- a/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb +++ b/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb @@ -31,13 +31,14 @@ before do organization.selected_invoice_custom_sections.concat(invoice_custom_sections[2..4]) + customer.selected_invoice_custom_sections.concat(invoice_custom_sections[0..1]) end it_behaves_like 'requires current user' it_behaves_like 'requires current organization' it_behaves_like 'requires permission', 'invoice_custom_sections:view' - it 'returns a list of sorted invoice_custom_sections: alphabetical, selected first' do + it 'returns a list of sorted invoice_custom_sections: alphabetical, selected first without duplicates' do result = execute_graphql( current_user: membership.user, current_organization: organization, diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb index b22169c920f..aea65daeb0d 100644 --- a/spec/models/customer_spec.rb +++ b/spec/models/customer_spec.rb @@ -433,8 +433,8 @@ context 'when customer does not have any selected_invoice_custom_sections but organization has' do before { organization.selected_invoice_custom_sections << organization_section } - it 'returns the organization\'s invoice_custom_sections' do - expect(customer.applicable_invoice_custom_sections).to eq([organization_section]) + it "returns the organization's invoice_custom_sections" do + expect(customer.applicable_invoice_custom_sections).to match_array([organization_section]) end end diff --git a/spec/requests/api/v1/customers_controller_spec.rb b/spec/requests/api/v1/customers_controller_spec.rb index e186c2752ae..1536156af22 100644 --- a/spec/requests/api/v1/customers_controller_spec.rb +++ b/spec/requests/api/v1/customers_controller_spec.rb @@ -266,7 +266,7 @@ sections = json[:customer][:applicable_invoice_custom_sections] expect(sections).to be_present expect(sections.length).to eq(2) - expect(sections.map { |sec| sec[:code] }).to eq(invoice_custom_sections.map(&:code)) + expect(sections.map { |sec| sec[:code] }).to match_array(invoice_custom_section_codes) end end diff --git a/spec/requests/api/v1/invoices_controller_spec.rb b/spec/requests/api/v1/invoices_controller_spec.rb index e781277fc37..a67dbce749c 100644 --- a/spec/requests/api/v1/invoices_controller_spec.rb +++ b/spec/requests/api/v1/invoices_controller_spec.rb @@ -177,7 +177,8 @@ customer: Hash, subscriptions: [], credits: [], - applied_taxes: [] + applied_taxes: [], + applied_invoice_custom_sections: [] ) expect(json[:invoice][:fees].first).to include(lago_charge_filter_id: charge_filter.id) end diff --git a/spec/serializers/v1/invoice_custom_section_serializer_spec.rb b/spec/serializers/v1/invoice_custom_section_serializer_spec.rb index 8e7456110a4..14b7a298c03 100644 --- a/spec/serializers/v1/invoice_custom_section_serializer_spec.rb +++ b/spec/serializers/v1/invoice_custom_section_serializer_spec.rb @@ -18,10 +18,8 @@ 'description' => invoice_custom_section.description, 'details' => invoice_custom_section.details, 'display_name' => invoice_custom_section.display_name, - 'selected_for_organization' => invoice_custom_section.selected_for_organization?, - 'organization' => { - 'lago_id' => invoice_custom_section.organization_id - } + 'applied_to_organization' => invoice_custom_section.selected_for_organization?, + 'organization_id' => invoice_custom_section.organization_id ) end end diff --git a/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb b/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb new file mode 100644 index 00000000000..f09e914b9f1 --- /dev/null +++ b/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V1::Invoices::AppliedInvoiceCustomSectionSerializer, type: :serializer do + subject(:serializer) { described_class.new(applied_invoice_custom_section) } + + let(:invoice) { create(:invoice) } + let(:applied_invoice_custom_section) do + create(:applied_invoice_custom_section, + invoice:, + code: 'custom_code', + details: 'custom_details', + display_name: 'Custom Display Name', + created_at: Time.current) + end + + describe '#serialize' do + it 'serializes the applied invoice custom section correctly' do + serialized_data = serializer.serialize + + expect(serialized_data).to include( + lago_id: applied_invoice_custom_section.id, + lago_invoice_id: applied_invoice_custom_section.invoice_id, + code: 'custom_code', + details: 'custom_details', + display_name: 'Custom Display Name', + created_at: applied_invoice_custom_section.created_at.iso8601 + ) + end + end +end diff --git a/spec/services/invoices/add_on_service_spec.rb b/spec/services/invoices/add_on_service_spec.rb index 7ca1edc1873..4430340863d 100644 --- a/spec/services/invoices/add_on_service_spec.rb +++ b/spec/services/invoices/add_on_service_spec.rb @@ -107,6 +107,10 @@ let(:service_call) { invoice_service.create } end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.create } + end + context 'with customer timezone' do before { applied_add_on.customer.update!(timezone: 'America/Los_Angeles') } diff --git a/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb b/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb new file mode 100644 index 00000000000..e74f6111e80 --- /dev/null +++ b/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Invoices::ApplyInvoiceCustomSectionsService, type: :service do + subject(:invoice_service) { described_class.new(invoice:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:) } + let(:custom_sections) { create_list(:invoice_custom_section, 3, organization:) } + + before do + organization.selected_invoice_custom_sections << custom_sections[1..2] + end + + describe '#call' do + context 'when the customer has skip_invoice_custom_sections flag' do + let(:customer) { create(:customer, organization:, skip_invoice_custom_sections: true) } + + it 'does not apply any custom sections' do + result = invoice_service.call + expect(result).to be_success + expect(result.applied_sections).to be_empty + expect(invoice.reload.applied_invoice_custom_sections).to be_empty + end + end + + context 'when the customer has custom sections' do + before do + customer.selected_invoice_custom_sections << custom_sections[0..1] + end + + it 'applies the custom sections to the invoice' do + result = invoice_service.call + expect(result).to be_success + sections = invoice.reload.applied_invoice_custom_sections + expect(sections.map(&:code)).to match_array(custom_sections[0..1].map(&:code)) + expect(sections.map(&:details)).to match_array(custom_sections[0..1].map(&:details)) + expect(sections.map(&:display_name)).to match_array(custom_sections[0..1].map(&:display_name)) + expect(sections.map(&:name)).to match_array(custom_sections[0..1].map(&:name)) + end + end + + context 'when the customer inherits custom sections from the organization' do + it 'applies the organization\'s sections to the invoice' do + result = invoice_service.call + expect(result).to be_success + sections = invoice.reload.applied_invoice_custom_sections + expect(sections.map(&:code)).to match_array(custom_sections[1..2].map(&:code)) + expect(sections.map(&:details)).to match_array(custom_sections[1..2].map(&:details)) + expect(sections.map(&:display_name)).to match_array(custom_sections[1..2].map(&:display_name)) + expect(sections.map(&:name)).to match_array(custom_sections[1..2].map(&:name)) + end + end + end +end diff --git a/spec/services/invoices/create_one_off_service_spec.rb b/spec/services/invoices/create_one_off_service_spec.rb index 501b0fdf269..af26b5f3f9c 100644 --- a/spec/services/invoices/create_one_off_service_spec.rb +++ b/spec/services/invoices/create_one_off_service_spec.rb @@ -67,6 +67,10 @@ let(:service_call) { create_service.call } end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { create_service.call } + end + it 'calls SegmentTrackJob' do invoice = create_service.call.invoice diff --git a/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb b/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb index d7147173e9e..4667671be93 100644 --- a/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb +++ b/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb @@ -318,5 +318,9 @@ expect(result.invoice).to be_finalized end end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end end end diff --git a/spec/services/invoices/paid_credit_service_spec.rb b/spec/services/invoices/paid_credit_service_spec.rb index 388171cb682..10dd929980c 100644 --- a/spec/services/invoices/paid_credit_service_spec.rb +++ b/spec/services/invoices/paid_credit_service_spec.rb @@ -62,6 +62,10 @@ let(:service_call) { invoice_service.call } end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + it 'does not enqueue an SendEmailJob' do expect do invoice_service.call diff --git a/spec/services/invoices/progressive_billing_service_spec.rb b/spec/services/invoices/progressive_billing_service_spec.rb index 3977b46faf4..7d9b2e75468 100644 --- a/spec/services/invoices/progressive_billing_service_spec.rb +++ b/spec/services/invoices/progressive_billing_service_spec.rb @@ -232,5 +232,9 @@ it_behaves_like 'syncs invoice' do let(:service_call) { create_service.call } end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { create_service.call } + end end end diff --git a/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb index c16e9f10714..dfc977abbfd 100644 --- a/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb +++ b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb @@ -79,6 +79,10 @@ let(:service_call) { finalize_service.call } end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { finalize_service.call } + end + it 'enqueues a SendWebhookJob' do expect do finalize_service.call diff --git a/spec/services/invoices/refresh_draft_service_spec.rb b/spec/services/invoices/refresh_draft_service_spec.rb index 590e0d4860d..b41e2837f72 100644 --- a/spec/services/invoices/refresh_draft_service_spec.rb +++ b/spec/services/invoices/refresh_draft_service_spec.rb @@ -171,6 +171,10 @@ .to change { invoice.reload.progressive_billing_credit_amount_cents }.from(1239000).to(0) end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { refresh_service.call } + end + context 'when there is a tax_integration set up' do let(:integration) { create(:anrok_integration, organization:) } let(:integration_customer) { create(:anrok_customer, integration:, customer:) } @@ -203,6 +207,21 @@ end end + context 'when invoice has other applied invoice_custom_sections' do + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 4, organization: organization) } + let(:applied_invoice_custom_sections) { create_list(:applied_invoice_custom_section, 2, invoice: invoice) } + + before do + applied_invoice_custom_sections + customer.selected_invoice_custom_sections = invoice_custom_sections.take(3) + end + + it 'creates new applied_invoice_custom_sections' do + expect { refresh_service.call }.to change { invoice.reload.applied_invoice_custom_sections.count }.from(2).to(3) + expect(invoice.applied_invoice_custom_sections.map(&:code)).to match_array(customer.selected_invoice_custom_sections.map(&:code)) + end + end + it 'flags lifetime usage for refresh' do create(:usage_threshold, plan: subscription.plan) diff --git a/spec/services/invoices/subscription_service_spec.rb b/spec/services/invoices/subscription_service_spec.rb index 9a53d022525..3c03494b7d1 100644 --- a/spec/services/invoices/subscription_service_spec.rb +++ b/spec/services/invoices/subscription_service_spec.rb @@ -106,6 +106,10 @@ let(:service_call) { invoice_service.call } end + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + it "enqueues a SendWebhookJob" do expect do invoice_service.call diff --git a/spec/support/shared_examples/applied_invoice_custom_sections.rb b/spec/support/shared_examples/applied_invoice_custom_sections.rb index e69de29bb2d..3b46340a9a4 100644 --- a/spec/support/shared_examples/applied_invoice_custom_sections.rb +++ b/spec/support/shared_examples/applied_invoice_custom_sections.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'applies invoice_custom_sections' do + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 4, organization:) } + + before do + organization.selected_invoice_custom_sections = invoice_custom_sections[2..3] + end + + context 'when the customer has :skip_invoice_custom_sections flag' do + before { customer.update(skip_invoice_custom_sections: true) } + + it 'doesn\'t create applied_invoice_custom_section' do + expect { service_call }.not_to change(AppliedInvoiceCustomSection, :count) + end + end + + context 'when customer follows organizations invoice_custom_sections' do + it 'creates applied_invoice_custom_sections' do + result = service_call + invoice = result.invoice + expect(invoice.applied_invoice_custom_sections.pluck(:code)).to match_array(organization.selected_invoice_custom_sections.pluck(:code)) + end + end +end