From 7755dfd33472684e693cff5a690c42bf10e4ecb9 Mon Sep 17 00:00:00 2001
From: Diego Steiner
Date: Wed, 20 Aug 2025 11:13:11 +0000
Subject: [PATCH] feature: seperate offers from invoices
---
app/controllers/manage/quotes_controller.rb | 59 +++++++++
.../manage/rich_text_templates_controller.rb | 2 +-
app/domain/booking_actions/email_contract.rb | 29 +++--
app/domain/booking_actions/email_offer.rb | 47 --------
app/domain/booking_actions/email_quote.rb | 47 ++++++++
app/domain/booking_flows/default.rb | 2 +-
.../booking_states/provisional_request.rb | 2 +-
app/models/booking.rb | 3 +-
app/models/booking_condition.rb | 4 +-
app/models/booking_state_checklist_item.rb | 12 +-
app/models/invoice.rb | 5 +-
app/models/invoice/factory.rb | 3 -
app/models/invoice/item.rb | 16 ++-
app/models/invoice/item_factory.rb | 22 ++--
app/models/invoice/items/add.rb | 13 +-
app/models/notification.rb | 2 +
app/models/organisation.rb | 1 +
app/models/quote.rb | 112 ++++++++++++++++++
app/models/tarif.rb | 2 +-
app/params/manage/quote_params.rb | 16 +++
app/serializers/manage/invoice_serializer.rb | 2 +
app/serializers/manage/quote_serializer.rb | 10 ++
app/services/attachment_manager.rb | 2 +-
app/services/export/pdf/invoice_pdf.rb | 31 ++---
app/services/quote_factory.rb | 38 ++++++
app/services/template_context.rb | 1 +
.../manage/bookings/_navigation.html.slim | 3 +
app/views/manage/invoices/index.html.slim | 18 ++-
app/views/manage/quotes/_form.html.slim | 25 ++++
app/views/manage/quotes/edit.html.slim | 7 ++
app/views/manage/quotes/index.html.slim | 73 ++++++++++++
app/views/manage/quotes/new.html.slim | 7 ++
config/locales/de.yml | 38 +++---
config/locales/en.yml | 18 +--
config/locales/fr.yml | 28 ++---
config/locales/it.yml | 28 ++---
config/routes.rb | 1 +
...820070823_seperate_offers_from_invoices.rb | 61 ++++++++++
db/schema.rb | 24 +++-
db/seeds/demo.json | 30 ++---
db/seeds/development.json | 30 ++---
.../booking_actions/email_contract_spec.rb | 9 ++
spec/factories/invoices.rb | 1 -
.../offer.rb => spec/factories/quotes.rb | 35 +++---
.../booking/booking_by_tenant_spec.rb | 4 +-
spec/models/invoice_spec.rb | 13 --
46 files changed, 700 insertions(+), 236 deletions(-)
create mode 100644 app/controllers/manage/quotes_controller.rb
delete mode 100644 app/domain/booking_actions/email_offer.rb
create mode 100644 app/domain/booking_actions/email_quote.rb
create mode 100644 app/models/quote.rb
create mode 100644 app/params/manage/quote_params.rb
create mode 100644 app/serializers/manage/quote_serializer.rb
create mode 100644 app/services/quote_factory.rb
create mode 100644 app/views/manage/quotes/_form.html.slim
create mode 100644 app/views/manage/quotes/edit.html.slim
create mode 100644 app/views/manage/quotes/index.html.slim
create mode 100644 app/views/manage/quotes/new.html.slim
create mode 100644 db/migrate/20250820070823_seperate_offers_from_invoices.rb
rename app/models/invoices/offer.rb => spec/factories/quotes.rb (62%)
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: |
- Offerte
-
- {{ TARIFS }}
- default_title: Offerte
- description: Text für Offerte
manage_cancelation_pending_notification:
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: |
+ Offerte
+
+ {{ TARIFS }}
+ default_title: Offerte
+ description: Text für Offerte
text_payment_info_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 }}
Promemoria {{booking.home.name}}
"
default_title: Promemoria
description: Testo per la fattura (promemoria)
- invoices_offer_text:
- default_body: Offerta
- default_title: Offerta
- description: Testo per l'offerta
manage_cancelation_pending_notification:
default_body:
default_title:
@@ -1855,9 +1851,6 @@ it:
description: Notifica di una nuova richiesta di affitto (amministrazione)
notification_footer:
description: Piè di pagina per le notifiche
- offer_notification:
- default_body:
- default_title: La vostra offerta
open_booking_agent_request_notification:
default_body:
default_title: Conferma della richiesta di affitto
@@ -2039,6 +2032,13 @@ it:
default_title: Prenotazione provvisoria confermata
description: Notifica di richiesta di affitto provvisoria (inquilino)
+ quote_notification:
+ default_body:
+ default_title: La vostra offerta
+ quote_text:
+ default_body: Offerta
+ default_title: Offerta
+ description: Testo per l'offerta
text_payment_info_text:
default_body: |
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 }