diff --git a/app/controllers/manage/quotes_controller.rb b/app/controllers/manage/quotes_controller.rb new file mode 100644 index 000000000..cec9d3259 --- /dev/null +++ b/app/controllers/manage/quotes_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Manage + class QuotesController < BaseController + load_and_authorize_resource :booking + load_and_authorize_resource :quote, through: :booking, shallow: true + + def index + @quotes = @quotes.where(booking: { organisation: current_organisation }) + .includes(:organisation).ordered.with_attached_pdf + @quotes = @quotes.where(booking: @booking) if @booking.present? + @quotes = @quotes.kept if @booking.blank? + + respond_with :manage, @quotes + end + + def show + @booking = @quote.booking + respond_to do |format| + format.pdf do + redirect_to url_for(@quote.pdf) + end + end + end + + def new + @quote = QuoteFactory.new(@booking).build(suggest_items: true, **quote_params) + respond_with :manage, @booking, @quote + end + + def edit + @booking = @quote.booking + respond_with :manage, @quote + end + + def create + @booking = @quote.booking + @quote.save + respond_with :manage, @quote, location: -> { manage_booking_quotes_path(@quote.booking) } + end + + def update + @booking = @quote.booking + @quote.update(quote_params) unless @quote.discarded? + respond_with :manage, @quote, location: -> { manage_booking_quotes_path(@quote.booking) } + end + + def destroy + @quote.discarded? || !@quote.sent? ? @quote.destroy : @quote.discard! + respond_with :manage, @quote, location: -> { manage_booking_quotes_path(@quote.booking) } + end + + private + + def quote_params + QuoteParams.new(params[:quote]).permitted + end + end +end diff --git a/app/controllers/manage/rich_text_templates_controller.rb b/app/controllers/manage/rich_text_templates_controller.rb index 77a6bd5f4..729b86777 100644 --- a/app/controllers/manage/rich_text_templates_controller.rb +++ b/app/controllers/manage/rich_text_templates_controller.rb @@ -77,7 +77,7 @@ def rich_text_templates_by_booking_action def rich_text_templates_by_document # rubocop:disable Metrics/MethodLength { - Invoices::Offer => @rich_text_templates.where(key: :invoices_offer_text), + Quote => @rich_text_templates.where(key: :quote_text), Invoices::Deposit => @rich_text_templates.where(key: :invoices_deposit_text), Invoices::Invoice => @rich_text_templates.where(key: :invoices_invoice_text), Invoices::LateNotice => @rich_text_templates.where(key: :invoices_late_notice_text), diff --git a/app/domain/booking_actions/email_contract.rb b/app/domain/booking_actions/email_contract.rb index 708134149..49a79bd8c 100644 --- a/app/domain/booking_actions/email_contract.rb +++ b/app/domain/booking_actions/email_contract.rb @@ -7,11 +7,12 @@ class EmailContract < Base delegate :contract, to: :booking - def invoke!(invoice_ids: invoices.map(&:id), current_user: nil) + def invoke!(invoice_ids: invoices.map(&:id), quote_ids: quotes.map(&:id), current_user: nil) invoices = self.invoices.where(id: invoice_ids) - mail = send_tenant_notification(invoices) + quotes = self.quotes.where(id: quote_ids) + mail = send_tenant_notification(invoices, quotes) - send_operator_notification(invoices) + send_operator_notification(invoices, quotes) Result.success redirect_proc: mail&.autodeliver_with_redirect_proc end @@ -25,10 +26,12 @@ def invokable_with(current_user: nil) return unless invokable?(current_user:) invoice_ids = invoices.map(&:to_param) + quote_ids = quotes.map(&:to_param) if invoices.any? - { label: translate(:label_with_invoice), params: { invoice_ids: } } + { label: translate(:label_with_invoice), params: { invoice_ids:, quote_ids: } } else - { label: translate(:label_without_invoice), confirm: translate(:confirm), params: { invoice_ids: [] } } + { label: translate(:label_without_invoice), confirm: translate(:confirm), + params: { invoice_ids: [], quote_ids: [] } } end end @@ -36,29 +39,35 @@ def invoices @invoices ||= booking.invoices.kept.unsent end + def quotes + @quotes ||= booking.quotes.unsent + end + def invoke_schema Dry::Schema.Params do optional(:invoice_ids).array(:string) + optional(:quote_ids).array(:string) end end protected - def send_tenant_notification(invoices) - context = { contract:, invoices: } + def send_tenant_notification(invoices, quotes) + context = { contract:, invoices:, quotes: } MailTemplate.use!(:email_contract_notification, booking, to: :tenant, context:) do |mail| mail.attach :contract, invoices mail.save! invoices.each { it.update!(sent_with_notification: mail) } + quotes.each { it.update!(sent_with_notification: mail) } contract.update!(sent_with_notification: mail) end end - def send_operator_notification(invoices) - context = { contract:, invoices: } + def send_operator_notification(invoices, quotes) + context = { contract:, invoices:, quotes: } Notification.dedup(booking, to: %i[billing home_handover home_return]) do |to| MailTemplate.use(:operator_email_contract_notification, booking, to:, context:)&.tap do |mail| - mail.attach contract, invoices + mail.attach contract, invoices, quotes mail.autodeliver! end end diff --git a/app/domain/booking_actions/email_offer.rb b/app/domain/booking_actions/email_offer.rb deleted file mode 100644 index 04e664ffd..000000000 --- a/app/domain/booking_actions/email_offer.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module BookingActions - class EmailOffer < Base - use_mail_template(:email_offer_notification, context: %i[booking offer], optional: true, autodeliver: false) - - def invoke!(offer_id:, current_user: nil) - offer = booking.organisation.invoices.find_by(type: Invoices::Offer.sti_name, id: offer_id) - mail = send_tenant_notification(offer) - - Result.success redirect_proc: mail&.autodeliver_with_redirect_proc - end - - def invokable?(offer_id:, current_user: nil) - booking.notifications_enabled && booking.tenant.email.present? && unsent_offers.exists?(id: offer_id) - end - - def invokable_with(current_user: nil) - unsent_offers.filter_map do |offer| - next unless invokable?(offer_id: offer.id, current_user:) - - { label:, params: { offer_id: offer.to_param } } - end - end - - def invoke_schema - Dry::Schema.Params do - optional(:offer_id).filled(:string) - end - end - - protected - - def send_tenant_notification(offer) - context = { offer: } - MailTemplate.use!(:email_offer_notification, booking, to: :tenant, context:).tap do |mail| - mail.attach(offer) - mail.save! - offer.update!(sent_at: Time.zone.now) - end - end - - def unsent_offers - booking.invoices.where(type: Invoices::Offer.sti_name).kept.unsent - end - end -end diff --git a/app/domain/booking_actions/email_quote.rb b/app/domain/booking_actions/email_quote.rb new file mode 100644 index 000000000..d2c1819a1 --- /dev/null +++ b/app/domain/booking_actions/email_quote.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module BookingActions + class EmailQuote < Base + use_mail_template(:email_quote_notification, context: %i[booking quote], optional: true, autodeliver: false) + + def invoke!(quote_id:, current_user: nil) + quote = booking.organisation.quotes.find_by(id: quote_id) + mail = send_tenant_notification(quote) + + Result.success redirect_proc: mail&.autodeliver_with_redirect_proc + end + + def invokable?(quote_id:, current_user: nil) + booking.notifications_enabled && booking.tenant.email.present? && unsent_quotes.exists?(id: quote_id) + end + + def invokable_with(current_user: nil) + unsent_quotes.filter_map do |quote| + next unless invokable?(quote_id: quote.id, current_user:) + + { label:, params: { quote_id: quote.to_param } } + end + end + + def invoke_schema + Dry::Schema.Params do + optional(:quote_id).filled(:string) + end + end + + protected + + def send_tenant_notification(quote) + context = { quote: } + MailTemplate.use!(:email_quote_notification, booking, to: :tenant, context:).tap do |mail| + mail.attach(quote) + mail.save! + quote.update!(sent_at: Time.zone.now) + end + end + + def unsent_quotes + booking.quotes.kept.unsent + end + end +end diff --git a/app/domain/booking_flows/default.rb b/app/domain/booking_flows/default.rb index c429ee82b..63324e19c 100644 --- a/app/domain/booking_flows/default.rb +++ b/app/domain/booking_flows/default.rb @@ -60,7 +60,7 @@ def self.manage_actions # rubocop:disable Metrics/MethodLength mark_contract_sent: BookingActions::MarkContractSent, mark_invoices_refunded: BookingActions::MarkInvoicesRefunded, email_invoice: BookingActions::EmailInvoice, - email_offer: BookingActions::EmailOffer, + email_quote: BookingActions::EmailQuote, postpone_deadline: BookingActions::PostponeDeadline, mark_contract_signed: BookingActions::MarkContractSigned, commit_request: BookingActions::CommitRequest, diff --git a/app/domain/booking_states/provisional_request.rb b/app/domain/booking_states/provisional_request.rb index 4fc31654f..b4bd1a427 100644 --- a/app/domain/booking_states/provisional_request.rb +++ b/app/domain/booking_states/provisional_request.rb @@ -7,7 +7,7 @@ class ProvisionalRequest < Base include Rails.application.routes.url_helpers def checklist - BookingStateChecklistItem.prepare(:offer_created, booking:) + BookingStateChecklistItem.prepare(:quote_created, booking:) end def self.to_sym diff --git a/app/models/booking.rb b/app/models/booking.rb index 06634fc7b..b61109a46 100644 --- a/app/models/booking.rb +++ b/app/models/booking.rb @@ -52,7 +52,7 @@ class Booking < ApplicationRecord # rubocop:disable Metrics/ClassLength ROLES = (%i[organisation tenant booking_agent] + OperatorResponsibility::RESPONSIBILITIES.keys).freeze LIMIT = ENV.fetch('RECORD_LIMIT', 250) DEFAULT_INCLUDES = [:organisation, :state_transitions, :invoices, :contracts, :payments, :booking_agent, - :category, :logs, :home, + :category, :logs, :home, :quotes, { tenant: :organisation, deadline: :booking, occupancies: :occupiable, agent_booking: %i[booking_agent organisation], booking_question_responses: :booking_question }].freeze @@ -64,6 +64,7 @@ class Booking < ApplicationRecord # rubocop:disable Metrics/ClassLength foreign_key: :booking_category_id has_many :invoices, dependent: :destroy + has_many :quotes, dependent: :destroy has_many :payments, dependent: :destroy has_many :notifications, dependent: :destroy, inverse_of: :booking, autosave: true, validate: false has_many :usages, -> { ordered }, dependent: :destroy, inverse_of: :booking diff --git a/app/models/booking_condition.rb b/app/models/booking_condition.rb index 416ef066b..106b23367 100644 --- a/app/models/booking_condition.rb +++ b/app/models/booking_condition.rb @@ -52,7 +52,9 @@ def initialize_copy(origin) end def self.one_of - StoreModel.one_of { |json| subtypes[(json[:type].presence || json['type'].presence)&.to_sym] || BookingCondition } + StoreModel.one_of do |json| + subtypes[(json&.[](:type).presence || json&.[]('type').presence)&.to_sym] || BookingCondition + end end def self.options_for_select(organisation) diff --git a/app/models/booking_state_checklist_item.rb b/app/models/booking_state_checklist_item.rb index dcff582eb..7cf585d61 100644 --- a/app/models/booking_state_checklist_item.rb +++ b/app/models/booking_state_checklist_item.rb @@ -70,17 +70,17 @@ class BookingStateChecklistItem BookingStateChecklistItem.new(key: :deposit_created, context: { booking: }, checked:, url:) end, - offer_created: lambda do |booking| - return if booking.organisation.rich_text_templates.enabled.by_key(:invoices_offer_text).blank? + quote_created: lambda do |booking| + return if booking.organisation.rich_text_templates.enabled.by_key(:quote_text).blank? - checked = Invoices::Offer.of(booking).kept.exists? + checked = booking.quotes.kept.exists? url = if checked - proc { manage_booking_invoices_path(it.booking) } + proc { manage_booking_quotes_path(it.booking) } else - proc { new_manage_booking_invoice_path(it.booking, invoice: { type: Invoices::Offer.model_name.to_s }) } + proc { new_manage_booking_quote_path(it.booking) } end - BookingStateChecklistItem.new(key: :offer_created, context: { booking: }, checked:, url:) + BookingStateChecklistItem.new(key: :quote_created, context: { booking: }, checked:, url:) end, responsibilities_assigned: lambda do |booking| diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 6fdc18dee..3365986af 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -58,12 +58,11 @@ class Invoice < ApplicationRecord attribute :items, Invoice::Item.one_of.to_array_type scope :ordered, -> { order(payable_until: :ASC, created_at: :ASC) } - scope :not_offer, -> { where.not(type: 'Invoices::Offer') } scope :sent, -> { where.not(sent_at: nil) } scope :unsent, -> { kept.where(sent_at: nil) } - scope :overdue, ->(at = Time.zone.today) { kept.not_offer.where(arel_table[:payable_until].lteq(at)) } + scope :overdue, ->(at = Time.zone.today) { kept.where(arel_table[:payable_until].lteq(at)) } scope :of, ->(booking) { where(booking:) } - scope :unsettled, -> { kept.not_offer.where(status: %i[outstanding refund]) } + scope :unsettled, -> { kept.where(status: %i[outstanding refund]) } scope :with_default_includes, -> { includes(%i[payments organisation]) } accepts_nested_attributes_for :items, reject_if: :all_blank, allow_destroy: true diff --git a/app/models/invoice/factory.rb b/app/models/invoice/factory.rb index cb9aa3012..31a6262f4 100644 --- a/app/models/invoice/factory.rb +++ b/app/models/invoice/factory.rb @@ -4,7 +4,6 @@ class Invoice class Factory RichTextTemplate.define(:invoices_deposit_text, context: %i[booking invoice]) RichTextTemplate.define(:invoices_invoice_text, context: %i[booking invoice]) - RichTextTemplate.define(:invoices_offer_text, context: %i[booking invoice]) RichTextTemplate.define(:invoices_late_notice_text, context: %i[booking invoice]) def initialize(booking) @@ -44,8 +43,6 @@ def prepare_to_supersede(invoice) end def payment_info_type(invoice) - return if invoice.type.to_s == Invoices::Offer.to_s - booking = invoice.booking country_code = booking.tenant&.country_code&.upcase return PaymentInfos::ForeignPaymentInfo if country_code && country_code != booking.organisation.country_code diff --git a/app/models/invoice/item.rb b/app/models/invoice/item.rb index 6748d7c0d..ec434574d 100644 --- a/app/models/invoice/item.rb +++ b/app/models/invoice/item.rb @@ -22,7 +22,7 @@ class Item attribute :deposit_id attribute :vat_category_id - delegate :booking, :organisation, to: :invoice, allow_nil: true + delegate :booking, :organisation, to: :parent, allow_nil: true validates :type, presence: true, inclusion: { in: ->(_) { Invoice::Item.subtypes.keys.map(&:to_s) } } validates :id, presence: true @@ -38,7 +38,11 @@ def type end def invoice - parent + parent if parent.is_a?(Invoice) + end + + def quote + parent if parent.is_a?(Quote) end def invoice=(value) @@ -46,7 +50,7 @@ def invoice=(value) end def usage - @usage ||= invoice&.booking&.usages&.find_by(id: usage_id) if usage_id.present? + @usage ||= parent&.booking&.usages&.find_by(id: usage_id) if usage_id.present? end def tarif @@ -62,7 +66,7 @@ def vat_category=(value) end def vat_category - @vat_category ||= invoice&.organisation&.vat_categories&.find_by(id: vat_category_id) if vat_category_id.present? + @vat_category ||= parent&.organisation&.vat_categories&.find_by(id: vat_category_id) if vat_category_id.present? end def calculated_amount @@ -75,7 +79,7 @@ def vat_breakdown def accounting_cost_center_nr @accounting_cost_center_nr ||= if super.to_s == 'home' - invoice.booking.home&.settings&.accounting_cost_center_nr.presence + parent.booking.home&.settings&.accounting_cost_center_nr.presence else super.presence end @@ -87,7 +91,7 @@ def to_sum(sum) def self.one_of StoreModel.one_of do |json| - subtypes[(json[:type].presence || json['type'].presence)&.to_sym] || Item + subtypes[(json&.[](:type).presence || json&.[]('type').presence)&.to_sym] || Item end end end diff --git a/app/models/invoice/item_factory.rb b/app/models/invoice/item_factory.rb index b0246c567..1cc9b428f 100644 --- a/app/models/invoice/item_factory.rb +++ b/app/models/invoice/item_factory.rb @@ -2,16 +2,18 @@ class Invoice class ItemFactory - attr_reader :invoice + attr_reader :parent - delegate :booking, :organisation, to: :invoice + delegate :booking, :organisation, to: :parent - def initialize(invoice) - @invoice = invoice + def initialize(parent) + @parent = parent end def build - I18n.with_locale(invoice.locale || I18n.locale) do + I18n.with_locale(parent.locale || I18n.locale) do + next build_from_usage_groups.compact if parent.is_a?(Quote) + [ build_from_deposits.presence, build_from_balance.presence, @@ -23,7 +25,7 @@ def build protected - def build_from_usage_groups(usages = booking.usages.ordered.where.not(id: invoice.items.map(&:usage_id))) + def build_from_usage_groups(usages = booking.usages.ordered.where.not(id: parent.items.map(&:usage_id))) usages.group_by(&:tarif_group).filter_map do |label, grouped_usages| usage_group_items = grouped_usages.map { build_from_usage(it) }.compact next unless usage_group_items.any? @@ -46,7 +48,7 @@ def build_from_balance def build_item(**attributes) item_class = attributes.delete(:class) || ::Invoice::Items::Add - item_class.new(invoice: invoice, suggested: true, **attributes) + item_class.new(parent:, suggested: true, **attributes) end def build_title(**attributes) @@ -54,7 +56,7 @@ def build_title(**attributes) end def deposits - booking.invoices.deposits.kept.where.not(id: invoice.id) + booking.invoices.deposits.kept.where.not(id: parent.id) end def build_from_deposits # rubocop:disable Metrics/AbcSize @@ -72,11 +74,11 @@ def build_from_deposits # rubocop:disable Metrics/AbcSize end def build_from_supersede_invoice - @invoice.supersede_invoice&.items&.map(&:dup) if @invoice.new_record? + parent.supersede_invoice&.items&.map(&:dup) if parent.new_record? end def build_from_usage(usage) - return unless usage.tarif&.associated_types&.include?(Tarif::ASSOCIATED_TYPES.key(invoice.class)) + return unless usage.tarif&.associated_types&.include?(Tarif::ASSOCIATED_TYPES.key(parent.class)) case usage.tarif when Tarifs::OvernightStay diff --git a/app/models/invoice/items/add.rb b/app/models/invoice/items/add.rb index 8969c4118..9924a5712 100644 --- a/app/models/invoice/items/add.rb +++ b/app/models/invoice/items/add.rb @@ -13,13 +13,18 @@ def calculated_amount end def accounting_account_nr_required? - !to_sum(0).zero? && organisation&.accounting_settings&.enabled && - (invoice.new_record? || invoice.created_at&.>(Time.zone.local(2025, 6, 1))) # TODO: remove after a year + parent.is_a?(Invoice) && !to_sum(0).zero? && organisation&.accounting_settings&.enabled && + !legacy_invoice? end def vat_category_required? - !to_sum(0).zero? && organisation&.accounting_settings&.liable_for_vat && - (invoice.new_record? || invoice.created_at&.>(Time.zone.local(2025, 6, 1))) # TODO: remove after a year + parent.is_a?(Invoice) && !to_sum(0).zero? && organisation&.accounting_settings&.liable_for_vat && + !legacy_invoice? + end + + # TODO: remove after a year + def legacy_invoice? + parent.is_a?(Invoice) && (parent&.new_record? || parent&.created_at&.>(Time.zone.local(2025, 6, 1))) end end end diff --git a/app/models/notification.rb b/app/models/notification.rb index a30d7ac60..e9a2d6e1d 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -30,6 +30,7 @@ class Notification < ApplicationRecord has_one :organisation, through: :booking has_many :contracts, foreign_key: :sent_with_notification_id, inverse_of: :sent_with_notification, dependent: :nullify has_many :invoices, foreign_key: :sent_with_notification_id, inverse_of: :sent_with_notification, dependent: :nullify + has_many :quotes, foreign_key: :sent_with_notification_id, inverse_of: :sent_with_notification, dependent: :nullify scope :unsent, -> { where(sent_at: nil) } before_save :deliver_to @@ -51,6 +52,7 @@ def deliver contracts.each { it.update(sent_at:) } invoices.each { it.update(sent_at:) } + quotes.each { it.update(sent_at:) } message_delivery.tap(&:deliver_later) end diff --git a/app/models/organisation.rb b/app/models/organisation.rb index c831435cb..de7a565ca 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -58,6 +58,7 @@ class Organisation < ApplicationRecord has_many :booking_questions, dependent: :destroy, inverse_of: :organisation has_many :payments, through: :bookings has_many :invoices, through: :bookings + has_many :quotes, through: :bookings has_many :journal_entry_batches, through: :invoices has_many :notifications, through: :bookings has_many :organisation_users, dependent: :destroy diff --git a/app/models/quote.rb b/app/models/quote.rb new file mode 100644 index 000000000..cc04584f6 --- /dev/null +++ b/app/models/quote.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: quotes +# +# id :bigint not null, primary key +# amount :decimal(, ) default(0.0) +# discarded_at :datetime +# issued_at :datetime +# items :jsonb +# locale :string +# ref :string +# sent_at :datetime +# sequence_number :integer +# sequence_year :integer +# text :text +# valid_until :datetime +# created_at :datetime not null +# updated_at :datetime not null +# booking_id :uuid +# sent_with_notification_id :bigint +# + +class Quote < ApplicationRecord + extend RichTextTemplate::Definition + include Subtypeable + include Discard::Model + include StoreModel::NestedAttributes + + locale_enum default: I18n.locale + delegate :currency, to: :organisation + + belongs_to :booking, inverse_of: :invoices, touch: true + belongs_to :sent_with_notification, class_name: 'Notification', optional: true + has_one :organisation, through: :booking + has_one_attached :pdf + + attr_accessor :skip_generate_pdf + + attribute :items, Invoice::Item.one_of.to_array_type + + scope :ordered, -> { order(valid_until: :ASC, created_at: :ASC) } + scope :sent, -> { where.not(sent_at: nil) } + scope :unsent, -> { kept.where(sent_at: nil) } + + accepts_nested_attributes_for :items, reject_if: :all_blank, allow_destroy: true + + before_save :sequence_number, :generate_ref, :recalculate + before_save :generate_pdf, if: :generate_pdf? + + validates :items, store_model: true + + def items + super || self.items = [] + end + + def generate_pdf? + kept? && !skip_generate_pdf && (changed? || pdf.blank?) + end + + def sequence_number + self[:sequence_number] ||= organisation.key_sequences.key(Quote.sti_name, year: sequence_year).lease! + end + + def sequence_year + self[:sequence_year] ||= created_at&.year || Time.zone.today.year + end + + def generate_pdf + I18n.with_locale(locale || I18n.locale) do + self.pdf = { io: StringIO.new(Export::Pdf::InvoicePdf.new(self).render_document), + filename:, content_type: 'application/pdf' } + end + end + + def generate_ref(force: false) + self.ref = RefBuilders::Invoice.new(self).generate if ref.blank? || force + end + + def sent? + sent_at.present? + end + + def recalculate + self.amount = items.reduce(0) { |sum, item| item.to_sum(sum) } || 0 + end + + def filename + "#{self.class.model_name.human} #{ref}.pdf" + end + + def sent! + update(sent_at: Time.zone.now) + end + + def to_s + ref + end + + def invoice_address + @invoice_address ||= InvoiceAddress.new(booking) + end + + def to_attachable + { io: StringIO.new(pdf.blob.download), filename:, content_type: pdf.content_type } if pdf&.blob.present? + end + + def vat_breakdown + items.group_by(&:vat_category).except(nil).transform_values { it.sum(&:calculated_amount) } || {} + end +end diff --git a/app/models/tarif.rb b/app/models/tarif.rb index 66b037803..5f9e0dbce 100644 --- a/app/models/tarif.rb +++ b/app/models/tarif.rb @@ -34,7 +34,7 @@ class Tarif < ApplicationRecord ASSOCIATED_TYPES = { deposit: Invoices::Deposit, invoice: Invoices::Invoice, late_notice: Invoices::LateNotice, - offer: Invoices::Offer, contract: ::Contract }.freeze + quote: Quote, contract: ::Contract }.freeze PREFILL_METHODS = { flat: -> { 1 }, days: -> { booking.nights + 1 }, diff --git a/app/params/manage/quote_params.rb b/app/params/manage/quote_params.rb new file mode 100644 index 000000000..af9af92b8 --- /dev/null +++ b/app/params/manage/quote_params.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Manage + class QuoteParams < ApplicationParams + def self.permitted_keys + %i[type text booking_id issued_at sent_at valid_until ref locale] + + [{ items_attributes: %i[id usage_id label breakdown amount type vat_category_id deposit_id + label_title accounting_account_nr accounting_cost_center_nr suggested ] }] + end + + sanitize do |params| + params[:text] = RichTextSanitizer.sanitize(params[:text]) if params[:text].present? + params + end + end +end diff --git a/app/serializers/manage/invoice_serializer.rb b/app/serializers/manage/invoice_serializer.rb index 93f72295e..37d2a89b1 100644 --- a/app/serializers/manage/invoice_serializer.rb +++ b/app/serializers/manage/invoice_serializer.rb @@ -6,5 +6,7 @@ class InvoiceSerializer < ApplicationSerializer fields :type, :text, :issued_at, :payable_until, :sent_at, :booking_id, :amount_paid, :percentage_paid, :amount, :locale, :payment_required, :ref, :payment_ref + + association :items, blueprint: Manage::InvoiceItemSerializer end end diff --git a/app/serializers/manage/quote_serializer.rb b/app/serializers/manage/quote_serializer.rb new file mode 100644 index 000000000..184e787df --- /dev/null +++ b/app/serializers/manage/quote_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Manage + class QuoteSerializer < ApplicationSerializer + identifier :id + fields :text, :issued_at, :valid_until, :sent_at, :booking_id, :amount, :locale, :ref + + association :items, blueprint: Manage::InvoiceItemSerializer + end +end diff --git a/app/services/attachment_manager.rb b/app/services/attachment_manager.rb index ea7b2dc36..652a66b3c 100644 --- a/app/services/attachment_manager.rb +++ b/app/services/attachment_manager.rb @@ -5,7 +5,7 @@ class AttachmentManager unsent_deposits: ->(booking) { booking.invoices.deposit.unsent }, unsent_invoices: ->(booking) { booking.invoices.invoice.unsent }, unsent_late_notices: ->(booking) { booking.invoices.late_notice.unsent }, - unsent_offers: ->(booking) { booking.invoices.offers.unsent }, + unsent_quotes: ->(booking) { booking.quotes.unsent }, contract: ->(booking) { booking.contract } }.freeze diff --git a/app/services/export/pdf/invoice_pdf.rb b/app/services/export/pdf/invoice_pdf.rb index be3cc7747..bcc9dc2d2 100644 --- a/app/services/export/pdf/invoice_pdf.rb +++ b/app/services/export/pdf/invoice_pdf.rb @@ -11,17 +11,17 @@ class InvoicePdf < Base PaymentInfos::TextPaymentInfo => Renderables::Invoice::TextPaymentInfo, PaymentInfos::OnArrival => nil }.freeze - attr_reader :invoice + attr_reader :parent - delegate :booking, :organisation, :payment_info, :invoice_address, to: :invoice + delegate :booking, :organisation, :invoice_address, to: :parent - def initialize(invoice) + def initialize(parent) super() - @invoice = invoice + @parent = parent end to_render do - render Renderables::PageHeader.new(text: invoice.ref || booking.ref, logo: organisation.logo) + render Renderables::PageHeader.new(text: parent.ref || booking.ref, logo: organisation.logo) end to_render do @@ -36,26 +36,29 @@ def initialize(invoice) end to_render do - next if invoice.is_a?(Invoices::Offer) + next unless parent.is_a?(Invoice) font_size(9) do - text "#{::Invoice.human_attribute_name(:ref)}: #{invoice.ref}" if invoice.ref.present? - text "#{::Invoice.human_attribute_name(:sent_at)}: #{I18n.l(invoice.sent_at&.to_date || Time.zone.today)}" - if invoice.payable_until - text "#{::Invoice.human_attribute_name(:payable_until)}: #{I18n.l(invoice.payable_until.to_date)}" + text "#{::Invoice.human_attribute_name(:ref)}: #{parent.ref}" if parent.ref.present? + text "#{::Invoice.human_attribute_name(:sent_at)}: #{I18n.l(parent.sent_at&.to_date || Time.zone.today)}" + if parent.payable_until + text "#{::Invoice.human_attribute_name(:payable_until)}: #{I18n.l(parent.payable_until.to_date)}" end - text "#{::Booking.human_attribute_name(:ref)}: #{invoice.booking.ref}" + text "#{::Booking.human_attribute_name(:ref)}: #{parent.booking.ref}" end end to_render do - special_tokens = { TARIFS: -> { render Renderables::Invoice::ItemsTable.new(invoice) } } - slices = Renderables::RichText.split(invoice.text, special_tokens) + special_tokens = { TARIFS: -> { render Renderables::Invoice::ItemsTable.new(parent) } } + slices = Renderables::RichText.split(parent.text, special_tokens) slices.each { render it } end to_render do - payment_info_renerable = payment_info&.show? && PAYMENT_INFOS.fetch(payment_info.class)&.new(payment_info) + next unless parent.is_a?(Invoice) + + payment_info_renerable = parent.payment_info&.show? && + PAYMENT_INFOS.fetch(parent.payment_info.class)&.new(parent.payment_info) render payment_info_renerable if payment_info_renerable end end diff --git a/app/services/quote_factory.rb b/app/services/quote_factory.rb new file mode 100644 index 000000000..835133c01 --- /dev/null +++ b/app/services/quote_factory.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class QuoteFactory + RichTextTemplate.define(:quote_text, context: %i[booking quote]) + + def initialize(booking) + @booking = booking + end + + def build(suggest_items: false, **attributes) + Quote.new(defaults.merge(attributes)).tap do |quote| + quote.text ||= text_from_template(quote) + quote.items ||= [] + quote.items += Invoice::ItemFactory.new(quote).build if suggest_items + end + end + + def defaults + { + issued_at: Time.zone.today, booking: @booking, locale: @booking.locale || I18n.locale + } + end + + protected + + def text_from_template(quote) + quote.organisation.rich_text_templates.enabled.by_key(:quote_text) + &.interpolate(template_context(quote), locale: quote.locale) + &.body + end + + def template_context(quote) + TemplateContext.new( + quote:, invoice: quote, booking: @booking, + costs: CostEstimation.new(@booking), organisation: @booking.organisation + ) + end +end diff --git a/app/services/template_context.rb b/app/services/template_context.rb index 667e2b0c4..d2e282a44 100644 --- a/app/services/template_context.rb +++ b/app/services/template_context.rb @@ -7,6 +7,7 @@ class TemplateContext Home => Manage::HomeSerializer, Payment => Manage::PaymentSerializer, Invoice => Manage::InvoiceSerializer, + Quote => Manage::QuoteSerializer, ::Invoice::Item => Manage::InvoiceItemSerializer, JournalEntryBatch => Manage::JournalEntryBatchSerializer, JournalEntryBatch::Entry => Manage::JournalEntrySerializer, diff --git a/app/views/manage/bookings/_navigation.html.slim b/app/views/manage/bookings/_navigation.html.slim index 1349a11d2..4baae7a23 100644 --- a/app/views/manage/bookings/_navigation.html.slim +++ b/app/views/manage/bookings/_navigation.html.slim @@ -15,6 +15,9 @@ li.nav-item a.nav-link[href=manage_booking_contracts_path(booking) class=('active' if active == :contracts)] = Contract.model_name.human(count: 2) + li.nav-item + a.nav-link[href=manage_booking_quotes_path(booking) class=('active' if active == :quotes)] + = Quote.model_name.human(count: 2) li.nav-item a.nav-link[href=manage_booking_invoices_path(booking) class=('active' if active == :invoices)] = Invoice.model_name.human(count: 2) diff --git a/app/views/manage/invoices/index.html.slim b/app/views/manage/invoices/index.html.slim index 7eaa9f616..18fea1d0a 100644 --- a/app/views/manage/invoices/index.html.slim +++ b/app/views/manage/invoices/index.html.slim @@ -100,16 +100,14 @@ td.align-middle - unless invoice.void? .text-end= number_to_currency(invoice.amount) - - - unless invoice.is_a?(Invoices::Offer) - .text-end= number_to_currency(-invoice.amount_paid) - .text-end.border-top.mt-1 - - if invoice.balance.negative? - .text-warning= number_to_currency(invoice.balance) - - elsif invoice.balance.zero? - .text-success= number_to_currency(invoice.balance) - - else - .text-danger= number_to_currency(invoice.balance) + .text-end= number_to_currency(-invoice.amount_paid) + .text-end.border-top.mt-1 + - if invoice.balance.negative? + .text-warning= number_to_currency(invoice.balance) + - elsif invoice.balance.zero? + .text-success= number_to_currency(invoice.balance) + - else + .text-danger= number_to_currency(invoice.balance) td.py-1.text-end.align-middle - if can?(:manage, invoice) diff --git a/app/views/manage/quotes/_form.html.slim b/app/views/manage/quotes/_form.html.slim new file mode 100644 index 000000000..2b4f42545 --- /dev/null +++ b/app/views/manage/quotes/_form.html.slim @@ -0,0 +1,25 @@ += form_with(model: [:manage, @booking, @quote], local: true, html: { novalidate: true }) do |f| + + fieldset[v-pre] + = f.hidden_field :booking_id + .row + .col-md-6 + = f.date_field :issued_at, lang: I18n.locale, help: t('optional') + .col-md-6 + = f.date_field :valid_until, lang: I18n.locale + + = f.text_area :text, class: 'rich-text-area' + + label.mb-2= ::Invoice::Item.model_name.human(count: 2) + - if @quote.errors.key?(:items) + p.invalid-feedback.d-block= @quote.errors.messages_for(:items).to_sentence.presence + = react_component('InvoiceItemsContainer', { \ + value: Manage::InvoiceItemSerializer.render_as_hash(@quote.items || [], view: :with_errors), \ + name: f.field_name(:items_attributes), \ + optionsForSelect: { \ + vatCategories: Public::VatCategorySerializer.render_as_hash(current_organisation.vat_categories) \ + } \ + }) + + .form-actions.pt-4.mt-3 + = f.submit class: 'btn btn-primary' diff --git a/app/views/manage/quotes/edit.html.slim b/app/views/manage/quotes/edit.html.slim new file mode 100644 index 000000000..6effcc675 --- /dev/null +++ b/app/views/manage/quotes/edit.html.slim @@ -0,0 +1,7 @@ += render partial: 'manage/bookings/navigation', locals: { active: :quotes, booking: @booking } + +.row.justify-content-center + .col-lg-10 + .card.shadow-sm + .card-body + == render 'form' diff --git a/app/views/manage/quotes/index.html.slim b/app/views/manage/quotes/index.html.slim new file mode 100644 index 000000000..bd6fb8625 --- /dev/null +++ b/app/views/manage/quotes/index.html.slim @@ -0,0 +1,73 @@ +- if @booking + - title "#{Booking.model_name.human} #{@booking.to_s} - #{Quote.model_name.human(count: 2)}" + = render partial: 'manage/bookings/navigation', locals: { active: :quotes, booking: @booking } +- else + h1.my-0= Quote.model_name.human(count: 2) + +- if @booking&.quotes&.none? + p.text-center.my-5 + = t(:no_records_yet, model_name: Quote.model_name.human(count: 2)) + =<> link_to(t(:add_record, model_name: Quote.model_name.human), new_manage_booking_quote_path(@booking) ) + +- else + .table-responsive + table.table.table-hover.align-middle + thead + tr + - unless @booking + th= Booking.model_name.human + th= Quote.model_name.human + th= Quote.human_attribute_name(:issued_at) + th= Quote.human_attribute_name(:valid_until) + th + .text-end= Quote.human_attribute_name(:amount) + th + + tbody.shadow-sm + - @quotes.each do |quote| + tr[data-href=manage_quote_path(quote) class=('disabled' if quote.discarded?)] + - unless @booking + td= link_to quote.booking, manage_booking_path(quote.booking) + td.align-middle + - if quote.pdf.attached? + = link_to manage_quote_path(quote, format: :pdf), target: :_blank do + = quote.ref || quote.payment_ref + span.ms-2.fa.fa-print + - else + = link_to (quote.ref), manage_quote_path(quote) + td.align-middle + - if quote.issued_at.present? + = l(quote.issued_at, format: :date) + - if quote.sent? + span.badge.badge-pill.bg-success.ms-1 + span.fa.fa-envelope-open.text-white[title="#{Quote.human_attribute_name(:sent_at)}: #{l(quote.sent_at)}"] + td.align-middle + - if quote.valid_until.present? + - if quote.valid_until.past? && !quote.paid? + .text-danger= l(quote.valid_until, format: :date) + .text-danger= distance_of_time_in_words_to_now(quote.valid_until) + - else + div=l(quote.valid_until, format: :date) + div= distance_of_time_in_words_to_now(quote.valid_until) + + td.align-middle + .text-end= number_to_currency(quote.amount) + + td.py-1.text-end.align-middle + - if can?(:manage, quote) + .btn-group + - unless quote.discarded? + - unless quote.sent? + - email_quote_action = quote.booking.booking_flow.manage_actions[:email_quote] + - if email_quote_action.invokable?(quote_id: quote.id) + = button_to manage_booking_invoke_action_path(quote.booking), method: :post, params: { id: email_quote_action.key, quote_id: quote.id }, class: 'btn btn-default' + span.fa.fa-paper-plane-o[title= email_quote_action.label] + + = link_to edit_manage_quote_path(quote), class: "btn btn-default border-0 p-2" do + span.fa.fa-edit + = link_to manage_quote_path(quote), data: { confirm: t(:confirm) }, method: :delete, class: 'btn btn-default' do + span.fa.fa-trash + +- if @booking && can?(:manage, @booking) + = link_to new_manage_booking_quote_path(@booking), class: 'btn btn-primary' + = t(:add_record, model_name: Quote.model_name.human) diff --git a/app/views/manage/quotes/new.html.slim b/app/views/manage/quotes/new.html.slim new file mode 100644 index 000000000..08519bf0c --- /dev/null +++ b/app/views/manage/quotes/new.html.slim @@ -0,0 +1,7 @@ += render partial: 'manage/bookings/navigation', locals: { active: :quotes, booking: @booking } + +.row.justify-content-center + .col-lg-8 + .card.shadow-sm + .card-body + == render 'form' diff --git a/config/locales/de.yml b/config/locales/de.yml index ab370cd41..50deee80b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -452,6 +452,16 @@ de: plan_b_backup: created_at: Erstellt am zip: Zip + quote: + amount: Betrag + booking_id: Buchung + issued_at: Ausgestellt am + items: Positionen + locale: Sprache + ref: Rechnungsnummer + sent_at: Rechnungsdatum + text: Text + valid_until: Gültig bis rich_text_template: autodeliver: Automatisch versenden body: Text @@ -629,7 +639,7 @@ de: unsent_deposits: Unversendete Akontorechnungen unsent_invoices: Unversendete Rechnungen unsent_late_notices: Unversendete Mahnungen - unsent_offers: Unversendete Offerte + unsent_quotes: Unversendete Offerte notification: to: administration: Verwaltung @@ -794,9 +804,6 @@ de: invoices/late_notice: one: Mahnung other: Mahnungen - invoices/offer: - one: Offerte - other: Offerten journal_entry_batch: one: Buchungssatzgruppe other: Buchungssatzgruppen @@ -833,6 +840,9 @@ de: plan_b_backup: one: Plan-B Backup other: Plan-B Backups + quote: + one: Offerte + other: Offerten rich_text_template: one: Textvorlage other: Textvorlagen @@ -903,7 +913,7 @@ de: email_invoice: label: Rechnung verschicken label_with_ref: "%{type} %{ref} verschicken" - email_offer: + email_quote: label: Offerte verschicken mark_contract_sent: label: Vertrag als versendet markieren @@ -971,7 +981,7 @@ de: invoice_paid: "%{invoice} bezahlt" invoice_refunded: "%{invoice} erstattet" invoices_paid: Rechnungen beglichen - offer_created: Offerte erstellen (optional) + quote_created: Offerte erstellen (optional) responsibilities_assigned: Verantwortlichkeiten zuweisen (optional) tarifs_chosen: Tarife festlegen usages_entered: Verbrauch erfassen @@ -2150,7 +2160,7 @@ de: {{ booking.organisation.payment_information }}
default_title: '{% if invoice.type == "Invoices::Deposit" %}Akontorechnung{% else %}Rechnung{% endif %} {{ invoice.ref }} für die Buchung {{ booking.ref }}' description: Benachrichtigung für Versand Rechnung (Mieter) - email_offer_notification: + email_quote_notification: default_body: |{{ booking.tenant.salutation }}
@@ -2192,13 +2202,6 @@ de: {{ TARIFS }} default_title: Mahnung description: Text für Rechnung (Mahnung) - invoices_offer_text: - default_body: | -Hallo
@@ -2610,6 +2613,13 @@ de: default_title: Provisorische Reservation bestätigt description: Benachrichtigung für provisorische Mietanfrage (Mieter) + quote_text: + default_body: | +Kontonummer: diff --git a/config/locales/en.yml b/config/locales/en.yml index 76f2cdf58..18a3c833d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -540,7 +540,7 @@ en: unsent_deposits: Unversendete Akontorechnungen unsent_invoices: Unversendete Rechnungen unsent_late_notices: Unversendete Mahnungen - unsent_offers: Unversendete Offerte + unsent_quotes: Unversendete Offerte notification: to: administration: Verwaltung @@ -699,9 +699,6 @@ en: invoices/late_notice: one: Mahnung other: Mahnungen - invoices/offer: - one: Offerte - other: Offerten journal_entry_batch: one: Buchungssatz other: Buchungssätze @@ -738,6 +735,9 @@ en: plan_b_backup: one: Plan-B Backup other: Plan-B Backups + quote: + one: Offerte + other: Offerten rich_text_template: one: Textvorlage other: Textvorlagen @@ -807,7 +807,7 @@ en: email_invoice: label: Rechnung verschicken label_with_ref: "%{type} %{ref} verschicken" - email_offer: + email_quote: label: Offerte verschicken mark_contract_sent: label: Vertrag als versendet markieren @@ -867,7 +867,7 @@ en: invoice_paid: "%{invoice} bezahlt" invoice_refunded: "%{invoice} erstattet" invoices_paid: Rechnungen beglichen - offer_created: Offerte erstellen (optional) + quote_created: Offerte erstellen (optional) responsibilities_assigned: Verantwortlichkeiten zuweisen tarifs_chosen: Tarife festlegen usages_entered: Verbrauch erfassen @@ -1797,7 +1797,7 @@ en: description: Benachrichtigung für Vertragsaustellung und Akontorechnung (Mieter) email_invoice_notification: description: Benachrichtigung für fällige Zahlung (Mieter) - email_offer_notification: + email_quote_notification: description: Benachrichtigung für Versand Offerte (Mieter) foreign_payment_info_text: description: Text für Zahlunginformationen für ausländische Mieter @@ -1807,8 +1807,6 @@ en: description: Text für Rechnung invoices_late_notice_text: description: Text für Rechnung (Mahnung) - invoices_offer_text: - description: Text für Offerte manage_cancelation_pending_notification: description: Benachrichtigung für stornierte Reservation (Verwaltung) manage_cancelled_request_notification: @@ -1851,6 +1849,8 @@ en: description: Benachrichtigung für überfällige Rechnung (Mieter) provisional_request_notification: description: Benachrichtigung für provisorische Mietanfrage (Mieter) + quote_text: + description: Text für Offerte text_payment_info_text: description: Text für Zahlunginformationen unconfirmed_request_notification: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 176fc195b..d8b71c1f7 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -591,7 +591,7 @@ fr: unsent_deposits: Acomptes non versés unsent_invoices: Factures non envoyées unsent_late_notices: Rappels non envoyés - unsent_offers: Offre non envoyée + unsent_quotes: Offre non envoyée notification: to: administration: Administration @@ -750,9 +750,6 @@ fr: invoices/late_notice: one: Rappel other: Rappels de paiement - invoices/offer: - one: Offre - other: Offres journal_entry_batch: one: other: @@ -789,6 +786,9 @@ fr: plan_b_backup: one: Backup other: Backups + quote: + one: Offre + other: Offres rich_text_template: one: Modèle de texte other: Modèles de texte @@ -852,7 +852,7 @@ fr: email_invoice: label: Envoyer une facture label_with_ref: - email_offer: + email_quote: label: Envoyer une offre mark_contract_sent: label: Marquer le contrat comme envoyé @@ -917,7 +917,7 @@ fr: invoice_paid: invoice_refunded: invoices_paid: Rechnung beglichen - offer_created: Offerte erstellen (optional) + quote_created: Offerte erstellen (optional) responsibilities_assigned: Attribuer des responsabilités (facultatif) tarifs_chosen: Tarife festlegen usages_entered: Saisir la consommation @@ -1691,7 +1691,7 @@ fr: default_body: default_title: description: - email_offer_notification: + email_quote_notification: default_body: default_title: description: Notification pour l'envoi de l'offre (locataire) @@ -1709,10 +1709,6 @@ fr: default_body: default_title: Rappel description: Texte pour facture de rappel - invoices_offer_text: - default_body: - default_title: Offre - description: Texte pour l'offre manage_cancelation_pending_notification: default_body: default_title: @@ -1735,9 +1731,6 @@ fr: description: Notification de nouvelle demande de location (administration) notification_footer: description: Pied de page pour les notifications - offer_notification: - default_body: - default_title: Ton offre open_booking_agent_request_notification: default_body: default_title: Confirmation de votre demande de location @@ -1797,6 +1790,13 @@ fr: default_body: default_title: Réservation provisoire confirmée description: Notification de demande de location provisoire (locataire) + quote_notification: + default_body: + default_title: Ton offre + quote_text: + default_body: + default_title: Offre + description: Texte pour l'offre text_payment_info_text: default_body: |
Numéro de compte: diff --git a/config/locales/it.yml b/config/locales/it.yml index 1381ae222..9b566a02f 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -591,7 +591,7 @@ it: unsent_deposits: unsent_invoices: unsent_late_notices: - unsent_offers: + unsent_quotes: notification: to: administration: Verwaltung @@ -750,9 +750,6 @@ it: invoices/late_notice: one: Promemoria other: Promemoria - invoices/offer: - one: Offerta - other: Offerte journal_entry_batch: one: other: @@ -789,6 +786,9 @@ it: plan_b_backup: one: other: + quote: + one: Offerta + other: Offerte rich_text_template: one: Modello di testo other: Modelli di testo @@ -852,7 +852,7 @@ it: email_invoice: label: Inviare la fattura label_with_ref: - email_offer: + email_quote: label: mark_contract_sent: label: @@ -917,7 +917,7 @@ it: invoice_paid: invoice_refunded: invoices_paid: - offer_created: Offerte erstellen (optional) + quote_created: Offerte erstellen (optional) responsibilities_assigned: Assegnare le responsabilità (opzionale) tarifs_chosen: Fissare le tariffe usages_entered: Registrare il consumo @@ -1771,7 +1771,7 @@ it: default_body: default_title: description: - email_offer_notification: + email_quote_notification: default_body: default_title: description: @@ -1797,10 +1797,6 @@ it: default_body: "
{{ booking.ref }} {{ booking.begins_at | datetime_format }} a {{ booking.ends_at | formato_data_ora }}
Numero di conto: diff --git a/config/routes.rb b/config/routes.rb index a9d355098..8fc424c38 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,7 @@ post :import, on: :collection resources :invoices, shallow: true + resources :quotes, shallow: true resources :payments, shallow: true resources :operator_responsibilities, except: %i[show] do post :assign, on: :collection diff --git a/db/migrate/20250820070823_seperate_offers_from_invoices.rb b/db/migrate/20250820070823_seperate_offers_from_invoices.rb new file mode 100644 index 000000000..04ea18fe9 --- /dev/null +++ b/db/migrate/20250820070823_seperate_offers_from_invoices.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class SeperateOffersFromInvoices < ActiveRecord::Migration[8.0] + def change + create_table :quotes do |t| + t.references :booking, type: :uuid, foreign_key: true + t.datetime :issued_at, null: true + t.datetime :valid_until, null: true + t.datetime :sent_at, null: true + t.text :text, null: true + t.decimal :amount, default: '0.0' + t.datetime :discarded_at, null: true + t.string :locale + t.integer :sequence_number + t.integer :sequence_year + t.string :ref, null: true + t.references :sent_with_notification, null: true, foreign_key: { to_table: :notifications } + t.jsonb :items + t.timestamps + end + + reversible do |direction| + direction.up do + migrate_rich_text_templates + migrate_key_sequences + migrate_offers + end + end + end + + def migrate_offers + table = Arel::Table.new(:invoices) + batch_size = 100 + offset = 0 + loop do + batch_query = table.project(Arel.star).where(table[:type].eq('Invoices::Offer')).take(batch_size).skip(offset) + offset += batch_size + rows = ActiveRecord::Base.connection.exec_query(batch_query.to_sql) + break if rows.empty? + + rows.each { migrate_offer(it) } + end + end + + def migrate_offer(offer_hash) + offer_hash.symbolize_keys! + quote = Quote.new(offer_hash.slice(*%i[booking_id text sent_at text amount discarded_at locale + sequence_number sequence_year ref sent_with_notification items])) + quote.assign_attributes(valid_until: offer_hash[:payable_until]) + quote.save + end + + def migrate_rich_text_templates + RichTextTemplate.where(key: 'invoices_offer_text').update_all(key: 'quote_text') # rubocop:disable Rails/SkipsModelValidations + RichTextTemplate.where(key: 'email_offer_notification').update_all(key: 'email_quote_notification') # rubocop:disable Rails/SkipsModelValidations + end + + def migrate_key_sequences + KeySequence.where(key: 'Invoices::Offer').update_all(key: 'Quote') # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/db/schema.rb b/db/schema.rb index 761d1b18a..c1f640175 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_19_103045) do +ActiveRecord::Schema[8.0].define(version: 2025_08_20_070823) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -577,6 +577,26 @@ t.index ["organisation_id"], name: "index_plan_b_backups_on_organisation_id" end + create_table "quotes", force: :cascade do |t| + t.uuid "booking_id" + t.datetime "issued_at" + t.datetime "valid_until" + t.datetime "sent_at" + t.text "text" + t.decimal "amount", default: "0.0" + t.datetime "discarded_at" + t.string "locale" + t.integer "sequence_number" + t.integer "sequence_year" + t.string "ref" + t.bigint "sent_with_notification_id" + t.jsonb "items" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["booking_id"], name: "index_quotes_on_booking_id" + t.index ["sent_with_notification_id"], name: "index_quotes_on_sent_with_notification_id" + end + create_table "rich_text_templates", force: :cascade do |t| t.string "key" t.datetime "created_at", precision: nil, null: false @@ -886,6 +906,8 @@ add_foreign_key "payments", "bookings" add_foreign_key "payments", "invoices" add_foreign_key "plan_b_backups", "organisations" + add_foreign_key "quotes", "bookings" + add_foreign_key "quotes", "notifications", column: "sent_with_notification_id" add_foreign_key "rich_text_templates", "organisations" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/seeds/demo.json b/db/seeds/demo.json index 24595ef9a..944b7fc65 100644 --- a/db/seeds/demo.json +++ b/db/seeds/demo.json @@ -120,7 +120,7 @@ "id": 1, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (1 Nacht)", "label_i18n": { "de": "Anzahlung (1 Nacht)" @@ -147,7 +147,7 @@ "id": 2, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (2 Nächte)", "label_i18n": { "de": "Anzahlung (2 Nächte)" @@ -174,7 +174,7 @@ "id": 3, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (3 Nächte)", "label_i18n": { "de": "Anzahlung (3 Nächte)" @@ -201,7 +201,7 @@ "id": 4, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (4 Nächte)", "label_i18n": { "de": "Anzahlung (4 Nächte)" @@ -228,7 +228,7 @@ "id": 5, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Lagertarif (unter 25 jährig)", "label_i18n": { "de": "Lagertarif (unter 25 jährig)" @@ -255,7 +255,7 @@ "id": 6, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Lagertarif (über 25 jährig)", "label_i18n": { "de": "Lagertarif (über 25 jährig)" @@ -282,7 +282,7 @@ "id": 7, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Mindestbelegung Lagertarif", "label_i18n": { "de": "Mindestbelegung Lagertarif" @@ -309,7 +309,7 @@ "id": 8, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Kurtaxe (ab 12 -18jährig)", "label_i18n": { "de": "Kurtaxe (ab 12 -18jährig)" @@ -363,7 +363,7 @@ "id": 10, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Kurtaxe (ab 18 Jahren)", "label_i18n": { "de": "Kurtaxe (ab 18 Jahren)" @@ -417,7 +417,7 @@ "id": 12, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Festtarrif 0 Nächte", "label_i18n": { "de": "Festtarrif 0 Nächte" @@ -471,7 +471,7 @@ "id": 14, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Strom (Hochtarif)", "label_i18n": { "de": "Strom (Hochtarif)" @@ -498,7 +498,7 @@ "id": 15, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Reservationsgebühr", "label_i18n": { "de": "Reservationsgebühr" @@ -525,7 +525,7 @@ "id": 16, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Strom (Niedertarif)", "label_i18n": { "de": "Strom (Niedertarif)" @@ -552,7 +552,7 @@ "id": 17, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Brennholz", "label_i18n": { "de": "Brennholz" @@ -579,7 +579,7 @@ "id": 18, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Abfall", "label_i18n": { "de": "Abfall" diff --git a/db/seeds/development.json b/db/seeds/development.json index 76eff853a..719e51a5a 100644 --- a/db/seeds/development.json +++ b/db/seeds/development.json @@ -120,7 +120,7 @@ "id": 1, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (1 Nacht)", "label_i18n": { "de": "Anzahlung (1 Nacht)" @@ -147,7 +147,7 @@ "id": 2, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (2 Nächte)", "label_i18n": { "de": "Anzahlung (2 Nächte)" @@ -174,7 +174,7 @@ "id": 3, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (3 Nächte)", "label_i18n": { "de": "Anzahlung (3 Nächte)" @@ -201,7 +201,7 @@ "id": 4, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["deposit", "offer", "contract"], + "associated_types": ["deposit", "quote", "contract"], "label": "Anzahlung (4 Nächte)", "label_i18n": { "de": "Anzahlung (4 Nächte)" @@ -228,7 +228,7 @@ "id": 5, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Lagertarif (unter 25 jährig)", "label_i18n": { "de": "Lagertarif (unter 25 jährig)" @@ -255,7 +255,7 @@ "id": 6, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Lagertarif (über 25 jährig)", "label_i18n": { "de": "Lagertarif (über 25 jährig)" @@ -282,7 +282,7 @@ "id": 7, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Mindestbelegung Lagertarif", "label_i18n": { "de": "Mindestbelegung Lagertarif" @@ -309,7 +309,7 @@ "id": 8, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Kurtaxe (ab 12 -18jährig)", "label_i18n": { "de": "Kurtaxe (ab 12 -18jährig)" @@ -363,7 +363,7 @@ "id": 10, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Kurtaxe (ab 18 Jahren)", "label_i18n": { "de": "Kurtaxe (ab 18 Jahren)" @@ -417,7 +417,7 @@ "id": 12, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Festtarrif 0 Nächte", "label_i18n": { "de": "Festtarrif 0 Nächte" @@ -471,7 +471,7 @@ "id": 14, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Strom (Hochtarif)", "label_i18n": { "de": "Strom (Hochtarif)" @@ -498,7 +498,7 @@ "id": 15, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Reservationsgebühr", "label_i18n": { "de": "Reservationsgebühr" @@ -525,7 +525,7 @@ "id": 16, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Strom (Niedertarif)", "label_i18n": { "de": "Strom (Niedertarif)" @@ -552,7 +552,7 @@ "id": 17, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Brennholz", "label_i18n": { "de": "Brennholz" @@ -579,7 +579,7 @@ "id": 18, "accounting_account_nr": "6000", "accounting_cost_center_nr": null, - "associated_types": ["invoice", "offer", "contract"], + "associated_types": ["invoice", "quote", "contract"], "label": "Abfall", "label_i18n": { "de": "Abfall" diff --git a/spec/domain/booking_actions/email_contract_spec.rb b/spec/domain/booking_actions/email_contract_spec.rb index 05a97f382..a0d749422 100644 --- a/spec/domain/booking_actions/email_contract_spec.rb +++ b/spec/domain/booking_actions/email_contract_spec.rb @@ -35,6 +35,15 @@ expect(booking_after_invoke.invoices.find(deposit.id)).to be_sent end end + + context 'with quote' do + let(:quote) { create(:quote, booking:) } + + it do + expect(quote.items).to be_present + expect(booking_after_invoke.quotes.find(quote.id)).to be_sent + end + end end describe '#invokable_with' do diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb index 79956afc1..23595a150 100644 --- a/spec/factories/invoices.rb +++ b/spec/factories/invoices.rb @@ -51,6 +51,5 @@ end factory :deposit, class: Invoices::Deposit.sti_name - factory :offer, class: Invoices::Offer.sti_name end end diff --git a/app/models/invoices/offer.rb b/spec/factories/quotes.rb similarity index 62% rename from app/models/invoices/offer.rb rename to spec/factories/quotes.rb index 05d8e908b..6cc7f5f8b 100644 --- a/app/models/invoices/offer.rb +++ b/spec/factories/quotes.rb @@ -6,7 +6,7 @@ # # id :bigint not null, primary key # amount :decimal(, ) default(0.0) -# balance :decimal(, ) +# balance :decimal(, ) # discarded_at :datetime # issued_at :datetime # items :jsonb @@ -19,6 +19,7 @@ # sent_at :datetime # sequence_number :integer # sequence_year :integer +# status :integer # text :text # type :string # created_at :datetime not null @@ -28,26 +29,24 @@ # supersede_invoice_id :bigint # -module Invoices - class Offer < ::Invoice - ::Invoice.register_subtype self do - scope :offers, -> { where(type: Invoices::Offer.sti_name) } +FactoryBot.define do + factory :quote do + booking + issued_at { 1.week.ago } + valid_until { 3.months.from_now } + text { Faker::Lorem.sentences } + transient do + skip_items { false } end - def balance - 0 - end - - def payment_info - nil - end - - def payment_required - false - end + after(:build) do |invoice, evaluator| + next if evaluator.skip_items - def sequence_number - self[:sequence_number] ||= organisation.key_sequences.key(Offer.sti_name, year: sequence_year).lease! + invoice.items = if evaluator.amount&.positive? + build_list(:invoice_item, 1, amount: evaluator.amount) + else + build_list(:invoice_item, 3) + end end end end diff --git a/spec/features/booking/booking_by_tenant_spec.rb b/spec/features/booking/booking_by_tenant_spec.rb index ee352ab17..e5f6483b2 100644 --- a/spec/features/booking/booking_by_tenant_spec.rb +++ b/spec/features/booking/booking_by_tenant_spec.rb @@ -9,12 +9,12 @@ let(:tenant) { create(:tenant, organisation:) } let(:deposit_tarifs) do create(:tarif, organisation:, tarif_group: 'Akontorechnung', - associated_types: %i[deposit offer contract]) + associated_types: %i[deposit quote contract]) end let(:invoice_tarifs) do create_list(:tarif, 2, organisation:, tarif_group: 'Übernachtungen', - associated_types: %i[invoice offer contract]) + associated_types: %i[invoice quote contract]) end let!(:tarifs) { [deposit_tarifs, invoice_tarifs].flatten } diff --git a/spec/models/invoice_spec.rb b/spec/models/invoice_spec.rb index 573b47f3f..d24cf9425 100644 --- a/spec/models/invoice_spec.rb +++ b/spec/models/invoice_spec.rb @@ -35,19 +35,6 @@ let(:organisation) { create(:organisation, :with_templates) } let(:invoice) { create(:invoice, organisation:) } - describe '::unsettled' do - subject(:unsettled) { described_class.unsettled } - - let!(:offer) { create(:invoice, type: Invoices::Offer) } - let!(:invoice) { create(:invoice) } - - it 'does not list the offer as unsettled' do - invoice.sent! - is_expected.to include(invoice) - is_expected.not_to include(offer) - end - end - describe '#payment_info' do subject { invoice.payment_info }