diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a5e53cd..84f7daabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Display custom error message if the PDF invoice is not found - Report subsription mismatch with user's group - Added sentry for error reporting +- Report details of the due for invoices related to a payment schedule - Fix a bug: unable to run test in negative timezones (#425) - Fix a bug: providing an array of attributes to filter OpenApi data, results in error - Fix a bug: unable to manage stocks on new products diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 3edc9f54b..1e31cf7ab 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -54,7 +54,7 @@ class Invoice < PaymentDocument # for debug & used by rake task "fablab:maintenance:regenerate_invoices" def regenerate_invoice_pdf - pdf = ::PDF::Invoice.new(self, invoice_items.find_by(object_type: Subscription.name)&.object&.expiration_date).render + pdf = ::PDF::Invoice.new(self).render File.binwrite(file, pdf) end @@ -199,7 +199,7 @@ class Invoice < PaymentDocument "main_item.object_id(#{main_item.object_id}), " \ "main_item.object_type(#{main_item.object_type}), user_id(#{invoicing_profile.user_id})" end - InvoiceWorker.perform_async(id, user&.subscription&.expired_at) + InvoiceWorker.perform_async(id) end def log_changes diff --git a/app/pdfs/pdf/invoice.rb b/app/pdfs/pdf/invoice.rb index 603ccc022..493346fdd 100644 --- a/app/pdfs/pdf/invoice.rb +++ b/app/pdfs/pdf/invoice.rb @@ -6,7 +6,8 @@ class PDF::Invoice < Prawn::Document include ActionView::Helpers::NumberHelper include ApplicationHelper - def initialize(invoice, subscription_expiration_date) + # @param invoice [Invoice] + def initialize(invoice) super(margin: 70) # fonts @@ -42,8 +43,8 @@ class PDF::Invoice < Prawn::Document text I18n.t('invoices.invoice_reference', REF: invoice.reference), leading: 3 end text I18n.t('invoices.code', CODE: Setting.get('invoice_code-value')), leading: 3 if Setting.get('invoice_code-active') - if invoice.main_item.object_type != WalletTransaction.name - order_number = invoice.main_item.object_type == OrderItem.name ? invoice.main_item.object.order.reference : invoice.order_number + if invoice.main_item&.object_type != WalletTransaction.name + order_number = invoice.main_item&.object_type == OrderItem.name ? invoice.main_item&.object&.order&.reference : invoice.order_number text I18n.t('invoices.order_number', NUMBER: order_number), leading: 3 end if invoice.is_a?(Avoir) @@ -80,60 +81,7 @@ class PDF::Invoice < Prawn::Document invoice.invoice_items.each do |item| price = item.amount.to_i / 100.00 - details = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : '' - - if item.object_type == Subscription.name - subscription = item.object - if invoice.main_item.object_type == 'OfferDay' - details += I18n.t('invoices.subscription_extended_for_free_from_START_to_END', - START: I18n.l(invoice.main_item.object.start_at.to_date), - END: I18n.l(invoice.main_item.object.end_at.to_date)) - else - subscription_end_at = case subscription_expiration_date - when Time - subscription_expiration_date - when String - DateTime.parse(subscription_expiration_date) - else - subscription.expiration_date - end - subscription_start_at = subscription_end_at - subscription.plan.duration - details += I18n.t('invoices.subscription_NAME_from_START_to_END', - NAME: item.description, - START: I18n.l(subscription_start_at.to_date), - END: I18n.l(subscription_end_at.to_date)) - end - - elsif item.object_type == Reservation.name - case invoice.main_item.object.try(:reservable_type) - ### Machine reservation - when 'Machine' - details += I18n.t('invoices.machine_reservation_DESCRIPTION', DESCRIPTION: item.description) - when 'Space' - details += I18n.t('invoices.space_reservation_DESCRIPTION', DESCRIPTION: item.description) - ### Training reservation - when 'Training' - details += I18n.t('invoices.training_reservation_DESCRIPTION', DESCRIPTION: item.description) - ### events reservation - when 'Event' - details += I18n.t('invoices.event_reservation_DESCRIPTION', DESCRIPTION: item.description) - # details of the number of tickets - if invoice.main_item.object.nb_reserve_places.positive? - details += "\n #{I18n.t('invoices.full_price_ticket', count: invoice.main_item.object.nb_reserve_places)}" - end - invoice.main_item.object.tickets.each do |t| - details += "\n #{I18n.t('invoices.other_rate_ticket', - count: t.booked, - NAME: t.event_price_category.price_category.name)}" - end - else - details += item.description - end - else - details += item.description - end - - data += [[details, number_to_currency(price)]] + data += [[Invoices::ItemLabelService.build(invoice, item), number_to_currency(price)]] total_calc += price total_ht += item.net_amount total_vat += item.vat @@ -221,71 +169,18 @@ class PDF::Invoice < Prawn::Document # payment details move_down 20 - if invoice.is_a?(Avoir) - payment_verbose = "#{I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date))} " - case invoice.payment_method - when 'stripe' - payment_verbose += I18n.t('invoices.by_card_online_payment') - when 'cheque' - payment_verbose += I18n.t('invoices.by_cheque') - when 'transfer' - payment_verbose += I18n.t('invoices.by_transfer') - when 'cash' - payment_verbose += I18n.t('invoices.by_cash') - when 'wallet' - payment_verbose += I18n.t('invoices.by_wallet') - when 'none' - payment_verbose = I18n.t('invoices.no_refund') - else - Rails.logger.error "specified refunding method (#{payment_verbose}) is unknown" - end - payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}" - else - # subtract the wallet amount for this invoice from the total - if invoice.wallet_amount - wallet_amount = invoice.wallet_amount / 100.00 - total -= wallet_amount - else - wallet_amount = nil - end - - # payment method - payment_verbose = if invoice.paid_by_card? - I18n.t('invoices.settlement_by_debit_card') - else - I18n.t('invoices.settlement_done_at_the_reception') - end - - # if the invoice was 100% payed with the wallet ... - payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount - - payment_verbose += " #{I18n.t('invoices.on_DATE_at_TIME', - DATE: I18n.l(invoice.created_at.to_date), - TIME: I18n.l(invoice.created_at, format: :hour_minute))}" - if total.positive? || !invoice.wallet_amount - payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}" - end - if invoice.wallet_amount - payment_verbose += if total.positive? - " #{I18n.t('invoices.and')} #{I18n.t('invoices.by_wallet')} " \ - "#{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}" - else - " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}" - end - end - end - text payment_verbose + text Invoices::PaymentDetailsService.build(invoice, total) # important information move_down 40 - txt = parse_html(Setting.get('invoice_text')) + txt = parse_html(Setting.get('invoice_text').to_s) txt.each_line do |line| text line, style: :bold, inline_format: true end # address and legals information move_down 40 - txt = parse_html(Setting.get('invoice_legals')) + txt = parse_html(Setting.get('invoice_legals').to_s) txt.each_line do |line| text line, align: :right, leading: 4, inline_format: true end @@ -303,21 +198,6 @@ class PDF::Invoice < Prawn::Document private - def reservation_dates_verbose(slot) - if slot.start_at.to_date == slot.end_at.to_date - "- #{I18n.t('invoices.on_DATE_from_START_to_END', - DATE: I18n.l(slot.start_at.to_date), - START: I18n.l(slot.start_at, format: :hour_minute), - END: I18n.l(slot.end_at, format: :hour_minute))}\n" - else - "- #{I18n.t('invoices.from_STARTDATE_to_ENDDATE_from_STARTTIME_to_ENDTIME', - STARTDATE: I18n.l(slot.start_at.to_date), - ENDDATE: I18n.l(slot.start_at.to_date), - STARTTIME: I18n.l(slot.start_at, format: :hour_minute), - ENDTIME: I18n.l(slot.end_at, format: :hour_minute))}\n" - end - end - ## # Remove every unsupported html tag from the given html text (like
, , ...).
# The supported tags are , , and
.
diff --git a/app/services/invoices/item_label_service.rb b/app/services/invoices/item_label_service.rb
new file mode 100644
index 000000000..ce83dc4ed
--- /dev/null
+++ b/app/services/invoices/item_label_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+# module definition
+module Invoices; end
+
+# Build a label for the given invoice item
+class Invoices::ItemLabelService
+ class << self
+ # @param invoice [Invoice]
+ # @param item [InvoiceItem]
+ # @return [String]
+ def build(invoice, item)
+ details = invoice.is_a?(Avoir) ? "#{I18n.t('invoices.cancellation')} - " : ''
+
+ if item.object_type == Subscription.name
+ "#{details}#{build_subscription_label(invoice, item)}"
+ elsif item.object_type == Reservation.name
+ "#{details}#{build_reservation_label(invoice, item)}"
+ else
+ "#{details}#{item.description}"
+ end
+ end
+
+ private
+
+ # @param invoice [Invoice]
+ # @param item [InvoiceItem]
+ # @return [String]
+ def build_subscription_label(invoice, item)
+ subscription = item.object
+ label = if invoice.main_item&.object_type == 'OfferDay'
+ I18n.t('invoices.subscription_extended_for_free_from_START_to_END',
+ START: I18n.l(invoice.main_item&.object&.start_at&.to_date),
+ END: I18n.l(invoice.main_item&.object&.end_at&.to_date))
+ else
+ subscription_end_at = subscription.expiration_date
+ subscription_start_at = subscription_end_at - subscription.plan.duration
+ I18n.t('invoices.subscription_NAME_from_START_to_END',
+ NAME: item.description,
+ START: I18n.l(subscription_start_at.to_date),
+ END: I18n.l(subscription_end_at.to_date))
+ end
+ unless invoice.payment_schedule_item.nil?
+ dues = invoice.payment_schedule_item.payment_schedule.payment_schedule_items.order(:due_date)
+ label += "\n #{I18n.t('invoices.from_payment_schedule',
+ NUMBER: dues.index(invoice.payment_schedule_item) + 1,
+ TOTAL: dues.count,
+ DATE: I18n.l(invoice.payment_schedule_item.due_date.to_date),
+ SCHEDULE: invoice.payment_schedule_item.payment_schedule.reference)}"
+ end
+ label
+ end
+
+ # @param invoice [Invoice]
+ # @param item [InvoiceItem]
+ # @return [String]
+ def build_reservation_label(invoice, item)
+ case invoice.main_item&.object.try(:reservable_type)
+ ### Machine reservation
+ when 'Machine'
+ I18n.t('invoices.machine_reservation_DESCRIPTION', DESCRIPTION: item.description)
+ when 'Space'
+ I18n.t('invoices.space_reservation_DESCRIPTION', DESCRIPTION: item.description)
+ ### Training reservation
+ when 'Training'
+ I18n.t('invoices.training_reservation_DESCRIPTION', DESCRIPTION: item.description)
+ ### events reservation
+ when 'Event'
+ build_event_reservation_label(invoice, item)
+ else
+ item.description
+ end
+ end
+
+ # @param invoice [Invoice]
+ # @param item [InvoiceItem]
+ # @return [String]
+ def build_event_reservation_label(invoice, item)
+ label = I18n.t('invoices.event_reservation_DESCRIPTION', DESCRIPTION: item.description)
+ # details of the number of tickets
+ if invoice.main_item&.object&.nb_reserve_places&.positive?
+ label += "\n #{I18n.t('invoices.full_price_ticket', count: invoice.main_item&.object&.nb_reserve_places)}"
+ end
+ invoice.main_item&.object&.tickets&.each do |t|
+ label += "\n #{I18n.t('invoices.other_rate_ticket',
+ count: t.booked,
+ NAME: t.event_price_category.price_category.name)}"
+ end
+ label
+ end
+ end
+end
diff --git a/app/services/invoices/label_service.rb b/app/services/invoices/label_service.rb
index fbd1dd604..b5a7ab01a 100644
--- a/app/services/invoices/label_service.rb
+++ b/app/services/invoices/label_service.rb
@@ -6,12 +6,14 @@ module Invoices; end
# Build a label for the given invoice
class Invoices::LabelService
class << self
+ # @param invoice [Invoice]
+ # @return [String, nil]
def build(invoice)
username = Invoices::RecipientService.name(invoice)
if invoice.is_a?(Avoir)
avoir_label(invoice)
else
- case invoice.main_item.object_type
+ case invoice.main_item&.object_type
when 'Reservation'
reservation_invoice_label(invoice, username)
when 'Subscription'
@@ -25,7 +27,7 @@ class Invoices::LabelService
when 'OrderItem'
I18n.t('invoices.order')
else
- Rails.logger.error "specified main_item.object_type type (#{invoice.main_item.object_type}) is unknown"
+ Rails.logger.error "specified main_item.object_type type (#{invoice.main_item&.object_type}) is unknown"
nil
end
end
@@ -33,12 +35,17 @@ class Invoices::LabelService
private
+ # @param invoice [Invoice]
+ # @return [String]
def avoir_label(invoice)
- return I18n.t('invoices.wallet_credit') if invoice.main_item.object_type == WalletTransaction.name
+ return I18n.t('invoices.wallet_credit') if invoice.main_item&.object_type == WalletTransaction.name
I18n.t('invoices.cancellation_of_invoice_REF', REF: invoice.invoice.reference)
end
+ # @param invoice [Invoice]
+ # @param username [String]
+ # @return [String]
def reservation_invoice_label(invoice, username)
label = I18n.t('invoices.reservation_of_USER_on_DATE_at_TIME',
USER: username,
@@ -55,6 +62,9 @@ class Invoices::LabelService
label
end
+ # @param subscription [Subscription]
+ # @param username [String]
+ # @return [String]
def subscription_label(subscription, username)
subscription_start_at = subscription.expired_at - subscription.plan.duration
duration_verbose = I18n.t("duration.#{subscription.plan.interval}", count: subscription.plan.interval_count)
@@ -64,6 +74,9 @@ class Invoices::LabelService
DATE: I18n.l(subscription_start_at.to_date))
end
+ # @param offer_day [OfferDay]
+ # @param username [String]
+ # @return [String]
def offer_day_label(offer_day, username)
I18n.t('invoices.subscription_of_NAME_extended_starting_from_STARTDATE_until_ENDDATE',
NAME: username,
diff --git a/app/services/invoices/payment_details_service.rb b/app/services/invoices/payment_details_service.rb
new file mode 100644
index 000000000..726c84f71
--- /dev/null
+++ b/app/services/invoices/payment_details_service.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+# module definition
+module Invoices; end
+
+# Build a localized string detailing the payment mean for the given invoice
+class Invoices::PaymentDetailsService
+ class << self
+ include ActionView::Helpers::NumberHelper
+
+ # @param invoice [Invoice]
+ # @param total [Float]
+ # @return [String]
+ def build(invoice, total)
+ if invoice.is_a?(Avoir)
+ build_avoir_details(invoice)
+ else
+ # subtract the wallet amount for this invoice from the total
+ if invoice.wallet_amount
+ wallet_amount = invoice.wallet_amount / 100.00
+ total -= wallet_amount
+ else
+ wallet_amount = nil
+ end
+
+ # payment method
+ payment_verbose = if invoice.paid_by_card?
+ I18n.t('invoices.settlement_by_debit_card')
+ else
+ I18n.t('invoices.settlement_done_at_the_reception')
+ end
+
+ # if the invoice was 100% payed with the wallet ...
+ payment_verbose = I18n.t('invoices.settlement_by_wallet') if total.zero? && wallet_amount
+
+ payment_verbose += " #{I18n.t('invoices.on_DATE_at_TIME',
+ DATE: I18n.l(invoice.created_at.to_date),
+ TIME: I18n.l(invoice.created_at, format: :hour_minute))}"
+ if total.positive? || !invoice.wallet_amount
+ payment_verbose += " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
+ end
+ if invoice.wallet_amount
+ payment_verbose += if total.positive?
+ " #{I18n.t('invoices.and')} #{I18n.t('invoices.by_wallet')} " \
+ "#{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
+ else
+ " #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(wallet_amount))}"
+ end
+ end
+ payment_verbose
+ end
+ end
+
+ private
+
+ # @param invoice [Invoice]
+ # @return [String]
+ def build_avoir_details(invoice)
+ details = "#{I18n.t('invoices.refund_on_DATE', DATE: I18n.l(invoice.avoir_date.to_date))} "
+ case invoice.payment_method
+ when 'stripe'
+ details += I18n.t('invoices.by_card_online_payment')
+ when 'cheque'
+ details += I18n.t('invoices.by_cheque')
+ when 'transfer'
+ details += I18n.t('invoices.by_transfer')
+ when 'cash'
+ details += I18n.t('invoices.by_cash')
+ when 'wallet'
+ details += I18n.t('invoices.by_wallet')
+ when 'none'
+ details = I18n.t('invoices.no_refund')
+ else
+ Rails.logger.error "specified refunding method (#{details}) is unknown"
+ end
+ "#{details} #{I18n.t('invoices.for_an_amount_of_AMOUNT', AMOUNT: number_to_currency(total))}"
+ end
+ end
+end
diff --git a/app/services/invoices/recipient_service.rb b/app/services/invoices/recipient_service.rb
index fc57073a1..4e64440a4 100644
--- a/app/services/invoices/recipient_service.rb
+++ b/app/services/invoices/recipient_service.rb
@@ -7,6 +7,8 @@ module Invoices; end
class Invoices::RecipientService
class << self
# Get the full name of the recipient for the given invoice.
+ # @param invoice [Invoice]
+ # @return [String]
def name(invoice)
if invoice.invoicing_profile.organization
name = invoice.invoicing_profile.organization.name
@@ -17,11 +19,15 @@ class Invoices::RecipientService
end
# Get the street address of the recipient for the given invoice.
+ # @param invoice [Invoice]
+ # @return [String]
def address(invoice)
invoice.invoicing_profile&.invoicing_address
end
# Get the optional data in profile_custom_fields, if the recipient is an organization
+ # @param invoice [Invoice]
+ # @return [Array