From a1ab854439743c82fbf065440f2c31bfb5f13c24 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 15 Jan 2026 14:58:45 +0200 Subject: [PATCH] Billing profile change support for paid invoices by admin --- .../admin/billing_profiles_controller.rb | 78 +++++++++- app/controllers/admin/invoices_controller.rb | 24 ++- app/models/billing_profile.rb | 5 + .../admin/billing_profiles/_form.html.erb | 60 ++++++++ .../admin/billing_profiles/edit.html.erb | 18 +++ .../admin/billing_profiles/index.html.erb | 9 +- app/views/admin/billing_profiles/new.html.erb | 18 +++ .../admin/billing_profiles/show.html.erb | 25 +++- app/views/admin/invoices/show.html.erb | 140 ++++++++++++------ config/locales/billing_profiles.en.yml | 12 ++ config/locales/billing_profiles.et.yml | 11 ++ config/locales/invoices.en.yml | 3 + config/locales/invoices.et.yml | 3 + config/routes.rb | 3 +- 14 files changed, 354 insertions(+), 55 deletions(-) create mode 100644 app/views/admin/billing_profiles/_form.html.erb create mode 100644 app/views/admin/billing_profiles/edit.html.erb create mode 100644 app/views/admin/billing_profiles/new.html.erb diff --git a/app/controllers/admin/billing_profiles_controller.rb b/app/controllers/admin/billing_profiles_controller.rb index a20ac3771..194349e41 100644 --- a/app/controllers/admin/billing_profiles_controller.rb +++ b/app/controllers/admin/billing_profiles_controller.rb @@ -1,10 +1,13 @@ +require 'countries' + module Admin class BillingProfilesController < BaseController before_action :authorize_user + before_action :set_billing_profile, only: %i[show edit update destroy] # GET /admin/billing_profiles def index - sort_column = params[:sort].presence_in(%w[users.surname name vat_code]) || 'users.surname' + sort_column = params[:sort].presence_in(%w[users.surname name vat_code]) || 'users.surname' sort_direction = params[:direction].presence_in(%w[asc desc]) || 'desc' billing_profiles = BillingProfile.accessible_by(current_ability) @@ -16,12 +19,81 @@ def index end # GET /admin/billing_profiles/12 - def show - @billing_profile = BillingProfile.accessible_by(current_ability).find(params[:id]) + def show; end + + # GET /admin/billing_profiles/new + def new + @billing_profile = BillingProfile.new + end + + # GET /admin/billing_profiles/12/edit + def edit; end + + # POST /admin/billing_profiles + def create + @billing_profile = BillingProfile.new(billing_profile_params) + + respond_to do |format| + if @billing_profile.save + format.html do + redirect_to admin_billing_profile_path(@billing_profile), notice: t('billing_profiles.created') + end + format.json { render :show, status: :created, location: admin_billing_profile_path(@billing_profile) } + else + format.html { render :new } + format.json { render json: @billing_profile.errors, status: :unprocessable_entity } + end + end + end + + # PUT /admin/billing_profiles/12 + def update + respond_to do |format| + if @billing_profile.update(update_params) + format.html do + redirect_to admin_billing_profile_path(@billing_profile), notice: t('billing_profiles.updated') + end + format.json { render :show, status: :ok, location: admin_billing_profile_path(@billing_profile) } + else + format.html { render :edit } + format.json { render json: @billing_profile.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /admin/billing_profiles/12 + def destroy + if @billing_profile.deletable? + @billing_profile.destroy + respond_to do |format| + format.html { redirect_to admin_billing_profiles_path, notice: t('billing_profiles.deleted') } + format.json { head :no_content } + end + else + respond_to do |format| + format.html { redirect_to admin_billing_profile_path(@billing_profile), alert: t('billing_profiles.in_use_by_offer') } + format.json { render json: @billing_profile.errors, status: :unprocessable_entity } + end + end end private + def set_billing_profile + @billing_profile = BillingProfile.accessible_by(current_ability).find(params[:id]) + end + + def billing_profile_params + params.require(:billing_profile) + .permit(:name, :vat_code, :street, :city, :postal_code, :country_code, :user_id) + end + + def update_params + update_params = params.require(:billing_profile) + .permit(:name, :vat_code, :street, :city, :postal_code, :country_code) + merge_updated_by(update_params) + end + def authorize_user authorize! :manage, BillingProfile end diff --git a/app/controllers/admin/invoices_controller.rb b/app/controllers/admin/invoices_controller.rb index d1e429d7d..028b56ad7 100644 --- a/app/controllers/admin/invoices_controller.rb +++ b/app/controllers/admin/invoices_controller.rb @@ -4,13 +4,14 @@ module Admin class InvoicesController < BaseController before_action :authorize_user - before_action :create_invoice_if_needed, except: :toggle_partial_payments - before_action :set_invoice, only: %i[show download update edit toggle_partial_payments] - before_action :authorize_for_update, only: %i[edit update] + before_action :create_invoice_if_needed, except: %i[toggle_partial_payments update_billing_profile] + before_action :set_invoice, only: %i[show download update edit toggle_partial_payments update_billing_profile] + before_action :authorize_for_update, only: %i[edit update update_billing_profile] # GET /admin/invoices/aa450f1a-45e2-4f22-b2c3-f5f46b5f906b def show @payment_orders = @invoice.payment_orders + @billing_profiles = @invoice.user&.billing_profiles || [] end # GET /admin/invoices @@ -83,6 +84,23 @@ def toggle_partial_payments end end + # PATCH /admin/invoices/aa450f1a-45e2-4f22-b2c3-f5f46b5f906b/update_billing_profile + def update_billing_profile + respond_to do |format| + if @invoice.update(billing_profile_id: params[:invoice][:billing_profile_id]) + format.html do + redirect_to admin_invoice_path(@invoice), notice: t('invoices.billing_profile_updated') + end + format.json { render :show, status: :ok, location: @invoice } + else + format.html do + redirect_to admin_invoice_path(@invoice), alert: @invoice.errors.full_messages.join(', ') + end + format.json { render json: @invoice.errors, status: :unprocessable_entity } + end + end + end + private def set_invoice diff --git a/app/models/billing_profile.rb b/app/models/billing_profile.rb index 81f1cbb04..ed7a70521 100644 --- a/app/models/billing_profile.rb +++ b/app/models/billing_profile.rb @@ -43,6 +43,11 @@ def vat_code_must_be_registered_in_vies return if vat_code.blank? || vat_rate == BigDecimal(0) errors.add(:vat_code, I18n.t('billing_profiles.vat_validation_error')) unless Valvat.new(vat_code).exists? + rescue Valvat::ServiceUnavailable + errors.add(:vat_code, I18n.t('billing_profiles.vat_validation_service_unavailable_error')) + rescue Valvat::MemberStateUnavailable + errors.add(:vat_code, I18n.t('billing_profiles.vat_validation_member_state_unavailable_error')) + true rescue Valvat::RateLimitError errors.add(:vat_code, I18n.t('billing_profiles.vat_validation_rate_limit_error')) end diff --git a/app/views/admin/billing_profiles/_form.html.erb b/app/views/admin/billing_profiles/_form.html.erb new file mode 100644 index 000000000..165a20de1 --- /dev/null +++ b/app/views/admin/billing_profiles/_form.html.erb @@ -0,0 +1,60 @@ +<%= form_with model: billing_profile, url: url, id: 'billing_profile_form' do |f| %> +
+ <%= f.label :name, t('billing_profiles.name'), style: 'width: 150px;' %> + <%= f.text_field :name, class: "form-control", autofocus: true, autocomplete: "off" %> +
+ +
+ <%= f.label :vat_code, t('billing_profiles.vat_code'), style: 'width: 150px;' %> + <%= f.text_field :vat_code, class: "form-control", autocomplete: "off" %> +
+ +
+ <%= f.label :street, t('billing_profiles.street'), style: 'width: 150px;' %> + <%= f.text_field :street, class: "form-control", autocomplete: "off" %> +
+ +
+ <%= f.label :city, t('billing_profiles.city'), style: 'width: 150px;' %> + <%= f.text_field :city, class: "form-control", autocomplete: "off" %> +
+ +
+ <%= f.label :postal_code, t('billing_profiles.postal_code'), style: 'width: 150px;' %> + <%= f.text_field :postal_code, class: "form-control", autocomplete: "off" %> +
+ +
+ <%= f.label :country_code, t('billing_profiles.country'), style: 'width: 150px;' %> + <%= f.select :country_code, + options_for_select( + Countries.for_selection, + billing_profile.country_code || Setting.find_by(code: 'default_country').retrieve + ), + {}, + class: "ui dropdown" %> +
+ +
+ <%= f.label :user_id, t('billing_profiles.user'), style: 'width: 150px;' %> + <% if billing_profile.new_record? %> + <%= f.select :user_id, + options_from_collection_for_select(User.order(:surname), :id, :display_name, billing_profile.user_id), + { include_blank: t('.select_user') }, + class: "ui dropdown" %> + <% elsif billing_profile.user.present? %> + <%= link_to billing_profile.user.display_name, admin_user_path(billing_profile.user) %> + <% else %> + <%= t('billing_profiles.orphaned') %> + <% end %> +
+ +
+
+ <%= f.button t(:submit), class: "c-btn c-btn--blue", data: { turbo: false } %> +
+ +
+ <%= link_to t(:back), admin_billing_profiles_path, class: "c-btn c-btn--green" %> +
+<% end %> diff --git a/app/views/admin/billing_profiles/edit.html.erb b/app/views/admin/billing_profiles/edit.html.erb new file mode 100644 index 000000000..890cf3b8c --- /dev/null +++ b/app/views/admin/billing_profiles/edit.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, t('.title') %> + +
+ <% if @billing_profile.errors.any? %> +
+
+ <%= t(:errors_name) %> +
+
    + <% @billing_profile.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + <%= render 'form', billing_profile: @billing_profile, url: admin_billing_profile_path(@billing_profile) %> +
diff --git a/app/views/admin/billing_profiles/index.html.erb b/app/views/admin/billing_profiles/index.html.erb index cb30a466a..a2cb28625 100644 --- a/app/views/admin/billing_profiles/index.html.erb +++ b/app/views/admin/billing_profiles/index.html.erb @@ -5,7 +5,7 @@ <%= form_with url: admin_billing_profiles_path, method: :get do |f| %>
- <%= f.search_field :search_string, value: params[:search_string], placeholder: t('search_by_domain_name'), class: 'c-table__search__input js-table-search-dt' %> + <%= f.search_field :search_string, value: params[:search_string], placeholder: 'Search by name', class: 'c-table__search__input js-table-search-dt' %> <%= f.button t('.search'), class: "c-btn c-btn--blue" %>
@@ -36,5 +36,12 @@ <% end %> <% end %> <% end %> + + <%= component 'common/pagy', pagy: @pagy %> + +
+ <%= link_to t('.new_billing_profile'), new_admin_billing_profile_path, class: "c-btn c-btn--green" %> +
+ diff --git a/app/views/admin/billing_profiles/new.html.erb b/app/views/admin/billing_profiles/new.html.erb new file mode 100644 index 000000000..81b0b4787 --- /dev/null +++ b/app/views/admin/billing_profiles/new.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, t('.title') %> + +
+ <% if @billing_profile.errors.any? %> +
+
+ <%= t(:errors_name) %> +
+
    + <% @billing_profile.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + <%= render 'form', billing_profile: @billing_profile, url: admin_billing_profiles_path %> +
diff --git a/app/views/admin/billing_profiles/show.html.erb b/app/views/admin/billing_profiles/show.html.erb index 482949928..c49c4c605 100644 --- a/app/views/admin/billing_profiles/show.html.erb +++ b/app/views/admin/billing_profiles/show.html.erb @@ -8,6 +8,11 @@ <%= component 'common/table', header_collection: [], options: { class: 'js-table-dt dataTable no-footer' } do %> <%= tag.tbody class: 'contents' do %> + + <%= t('billing_profiles.name') %> + <%= @billing_profile.name %> + + <%= t('billing_profiles.vat_code') %> <%= @billing_profile.vat_code %> @@ -32,13 +37,25 @@ <%= t('billing_profiles.country') %> <%= @billing_profile.country_code %> + + + <%= t('billing_profiles.user') %> + + <% if @billing_profile.user %> + <%= link_to @billing_profile.user.display_name, admin_user_path(@billing_profile.user) %> + <% else %> + <%= t('billing_profiles.orphaned') %> + <% end %> + + <% end %> <% end %> -
- <%- if @billing_profile.user %> - <%= link_to t(:user), admin_user_path(@billing_profile.user), class: "c-btn c-btn--blue" %> - <% end %> +
+ <%= link_to t(:edit), edit_admin_billing_profile_path(@billing_profile), class: "c-btn c-btn--orange" %> + <%= button_to t(:delete), admin_billing_profile_path(@billing_profile), method: :delete, class: "c-btn c-btn--red", form: { data: { turbo_confirm: t(".confirm_delete") } } %> + <%= link_to t(:versions_name), admin_billing_profile_versions_path(@billing_profile), class: "c-btn c-btn--blue" %> + <%= link_to t(:back), admin_billing_profiles_path, class: "c-btn c-btn--gray" %>
diff --git a/app/views/admin/invoices/show.html.erb b/app/views/admin/invoices/show.html.erb index e202130d2..2def84c22 100644 --- a/app/views/admin/invoices/show.html.erb +++ b/app/views/admin/invoices/show.html.erb @@ -1,51 +1,98 @@ <% content_for :title, t('.title', invoice_number: @invoice&.number) %>
-
- <%= link_to t(:versions_name), admin_invoice_versions_path(@invoice), class: "ui button primary" %> - <%= link_to t('invoices.download'), download_admin_invoice_path(@invoice), - { class: 'ui button secondary', download: true } %> - <% unless @invoice.overdue? || @invoice.paid? %> - <%= link_to t('invoices.mark_as_paid'), edit_admin_invoice_path(@invoice), class: "ui button secondary" %> - <% action = @invoice.partial_payments? ? "disallow" : "allow" %> - <%= button_to t("invoices.#{action}_partial_payments"), toggle_partial_payments_admin_invoice_path(@invoice), class: "ui button secondary", form: { data: { 'turbo-confirm': 'Are you sure?' } } %> - <% end %> -
-
-
-
-
<%= t('invoices.status') %>
- <%= I18n.t("activerecord.enums.invoice.statuses.#{@invoice.status}") %> -
<%= t('invoices.issued_for') %>
- <%= @invoice.recipient %>
- <%= @invoice.address %> -
<%= t(:updated_by) %>
- <%= @invoice.updated_by %> - <% if @invoice.notes %> -
-
<%= t('invoices.notes') %>
- <%= @invoice.notes %> +