From 26636254bd5656ba915a3eed62ea0bab6bd29a4f Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 25 Jan 2021 13:05:27 +0100 Subject: [PATCH] generate the invoices for each schedule item and notify if something goes wrong --- app/models/notification_type.rb | 3 + app/models/payment_schedule_item.rb | 4 + app/services/payment_schedule_service.rb | 122 ++++++++++++++---- ...ment_schedule_check_deadline.json.jbuilder | 4 + ...dmin_payment_schedule_failed.json.jbuilder | 4 + ...mber_payment_schedule_failed.json.jbuilder | 4 + ...n_payment_schedule_check_deadline.html.erb | 10 ++ ...ify_admin_payment_schedule_failed.html.erb | 10 ++ ...ify_admin_user_wallet_is_credited.html.erb | 2 +- ...fy_member_payment_schedule_failed.html.erb | 10 ++ app/workers/payment_schedule_item_worker.rb | 10 +- config/locales/app.shared.en.yml | 2 + config/locales/app.shared.fr.yml | 2 + config/locales/en.yml | 6 + config/locales/fr.yml | 6 + config/locales/mails.en.yml | 18 +++ config/locales/mails.fr.yml | 18 +++ 17 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder create mode 100644 app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder create mode 100644 app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder create mode 100644 app/views/notifications_mailer/notify_admin_payment_schedule_check_deadline.html.erb create mode 100644 app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb create mode 100644 app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb diff --git a/app/models/notification_type.rb b/app/models/notification_type.rb index 1478f02dd..d80edafdd 100644 --- a/app/models/notification_type.rb +++ b/app/models/notification_type.rb @@ -55,6 +55,9 @@ class NotificationType notify_user_role_update notify_admin_members_stripe_sync notify_user_when_payment_schedule_ready + notify_admin_payment_schedule_failed + notify_member_payment_schedule_failed + notify_admin_payment_schedule_check_deadline ] # deprecated: # - notify_member_subscribed_plan_is_changed diff --git a/app/models/payment_schedule_item.rb b/app/models/payment_schedule_item.rb index f64d81bec..b68bcd306 100644 --- a/app/models/payment_schedule_item.rb +++ b/app/models/payment_schedule_item.rb @@ -6,6 +6,10 @@ class PaymentScheduleItem < Footprintable belongs_to :invoice after_create :chain_record + def first? + payment_schedule.ordered_items.first == self + end + def self.columns_out_of_footprint %w[invoice_id] end diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 1c93407b7..a7785877d 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -69,67 +69,135 @@ class PaymentScheduleService ps end + ## + # Generate the invoice associated with the given PaymentScheduleItem, with the children elements (InvoiceItems). + # @param stp_invoice is used to determine if the invoice was paid using stripe + ## def generate_invoice(payment_schedule_item, stp_invoice = nil) + # build the base invoice invoice = Invoice.new( invoiced: payment_schedule_item.payment_schedule.scheduled, invoicing_profile: payment_schedule_item.payment_schedule.invoicing_profile, - statistic_profile: payment_schedule_item.payment_schedule.user.statistic_profile, + statistic_profile: payment_schedule_item.payment_schedule.user&.statistic_profile, operator_profile_id: payment_schedule_item.payment_schedule.operator_profile_id, stp_payment_intent_id: stp_invoice&.payment_intent, payment_method: stp_invoice ? 'stripe' : nil ) + # complete the invoice with some InvoiceItem + if payment_schedule_item.first? + complete_first_invoice(payment_schedule_item, invoice) + else + complete_next_invoice(payment_schedule_item, invoice) + end - generate_invoice_items(invoice, payment_schedule_item, reservation: reservation, subscription: subscription) - InvoicesService.set_total_and_coupon(invoice, user, payment_details[:coupon]) - invoice + # set the total and apply any coupon + user = payment_schedule_item.payment_schedule.user + coupon = payment_schedule_item.payment_schedule.coupon + set_total_and_coupon(payment_schedule_item, invoice, user, coupon) + + # save the results + invoice.save + payment_schedule_item.update_attributes(invoice_id: invoice.id) end private - def generate_invoice_items(invoice, payment_details, reservation: nil, subscription: nil) - if reservation - case reservation.reservable - # === Event reservation === - when Event - InvoicesService.generate_event_item(invoice, reservation, payment_details) - # === Space|Machine|Training reservation === - else - InvoicesService.generate_generic_item(invoice, reservation, payment_details) - end + ## + # The first PaymentScheduleItem contains references to the reservation price (if any) and to the adjustement price + # for the subscription (if any) + ## + def complete_first_invoice(payment_schedule_item, invoice) + # sub-prices for the subscription and the reservation + details = {} + if payment_schedule_item.payment_schedule.scheduled_type == Subscription.name + details[:subscription] = payment_schedule_item.details['recurring'] + payment_schedule_item.details['adjustment'] + else + details[:reservation] = payment_schedule_item.details['other_items'] end - return unless subscription || reservation&.plan_id + # the subscription and reservation items + subscription = Subscription.find(payment_schedule_item.details['subscription_id']) + if payment_schedule_item.payment_schedule.scheduled_type == Reservation.name + reservation = payment_schedule_item.payment_schedule.scheduled + end - subscription = reservation.generate_subscription if !subscription && reservation.plan_id - InvoicesService.generate_subscription_item(invoice, subscription, payment_details) + # build the invoice items + generate_invoice_items(invoice, details, subscription: subscription, reservation: reservation) end + ## + # The later PaymentScheduleItems only contain references to the subscription (which is recurring) + ## + def complete_next_invoice(payment_schedule_item, invoice) + # the subscription item + subscription = Subscription.find(payment_schedule_item.details['subscription_id']) + + # sub-price for the subscription + details = { subscription: payment_schedule_item.details['recurring'] } + + # build the invoice item + generate_invoice_items(invoice, details, subscription: subscription) + end + + ## + # Generate an array of InvoiceItem according to the provided parameters and saves them in invoice.invoice_items + ## + def generate_invoice_items(invoice, payment_details, reservation: nil, subscription: nil) + generate_reservation_item(invoice, reservation, payment_details) if reservation + + return unless subscription + + generate_subscription_item(invoice, subscription, payment_details) + end + + ## + # Generate a single InvoiceItem for the given reservation and save it in invoice.invoice_items. + # This method must be called only with a valid reservation + ## def generate_reservation_item(invoice, reservation, payment_details) raise TypeError unless [Space, Machine, Training].include? reservation.reservable.class + description = reservation.reservable.name reservation.slots.each do |slot| - description = reservation.reservable.name + - " #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}" - - price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] } - invoice.invoice_items.push InvoiceItem.new( - amount: price_slot[:price], - description: description - ) + description += " #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}\n" end + + invoice.invoice_items.push InvoiceItem.new( + amount: payment_details[:reservation], + description: description + ) end ## # Generate an InvoiceItem for the given subscription and save it in invoice.invoice_items. # This method must be called only with a valid subscription ## - def self.generate_subscription_item(invoice, subscription, payment_details) + def generate_subscription_item(invoice, subscription, payment_details) raise TypeError unless subscription invoice.invoice_items.push InvoiceItem.new( - amount: payment_details[:elements][:plan], + amount: payment_details[:subscription], description: subscription.plan.name, subscription_id: subscription.id ) end + + ## + # Set the total price to the invoice, summing all sub-items. + # Additionally a coupon may be applied to this invoice to make a discount on the total price + ## + def set_total_and_coupon(payment_schedule_item, invoice, user, coupon = nil) + return unless invoice + + total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) + + unless coupon.nil? + if (coupon.validity_per_user == 'once' && payment_schedule_item.first?) || coupon.validity_per_user == 'forever' + total = CouponService.new.apply(total, coupon, user.id) + invoice.coupon_id = coupon.id + end + end + + invoice.total = total + end end diff --git a/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder b/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder new file mode 100644 index 000000000..08d9326d9 --- /dev/null +++ b/app/views/api/notifications/_notify_admin_payment_schedule_check_deadline.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.schedule_deadline') diff --git a/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder b/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder new file mode 100644 index 000000000..480155950 --- /dev/null +++ b/app/views/api/notifications/_notify_admin_payment_schedule_failed.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.schedule_failed') diff --git a/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder b/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder new file mode 100644 index 000000000..480155950 --- /dev/null +++ b/app/views/api/notifications/_notify_member_payment_schedule_failed.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.title notification.notification_type +json.description t('.schedule_failed') diff --git a/app/views/notifications_mailer/notify_admin_payment_schedule_check_deadline.html.erb b/app/views/notifications_mailer/notify_admin_payment_schedule_check_deadline.html.erb new file mode 100644 index 000000000..e57d228fd --- /dev/null +++ b/app/views/notifications_mailer/notify_admin_payment_schedule_check_deadline.html.erb @@ -0,0 +1,10 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.remember', + REFERENCE: @attached_object.payment_schedule.reference, + AMOUNT: number_to_currency(@attached_object.amount), + DATE: I18n.l @attached_object.due_date, format: :long) %> + <%= t('.body.date') %> +

+

<%= t('.body.confirm') %>

diff --git a/app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb b/app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb new file mode 100644 index 000000000..2262cb8f0 --- /dev/null +++ b/app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb @@ -0,0 +1,10 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.remember', + REFERENCE: @attached_object.payment_schedule.reference, + AMOUNT: number_to_currency(@attached_object.amount), + DATE: I18n.l @attached_object.due_date, format: :long) %> + <%= t('.body.error') %> +

+

<%= t('.body.action') %>

diff --git a/app/views/notifications_mailer/notify_admin_user_wallet_is_credited.html.erb b/app/views/notifications_mailer/notify_admin_user_wallet_is_credited.html.erb index 3e5f2a27f..b03e5aad2 100644 --- a/app/views/notifications_mailer/notify_admin_user_wallet_is_credited.html.erb +++ b/app/views/notifications_mailer/notify_admin_user_wallet_is_credited.html.erb @@ -1,4 +1,4 @@ -<%# this is a mail template of notifcation notify_admin_user_wallet_is_credited %> +<%# this is a mail template of notification notify_admin_user_wallet_is_credited %> <%= render 'notifications_mailer/shared/hello', recipient: @recipient %>

<%= t('.body.wallet_credit_html', diff --git a/app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb b/app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb new file mode 100644 index 000000000..2262cb8f0 --- /dev/null +++ b/app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb @@ -0,0 +1,10 @@ +<%= render 'notifications_mailer/shared/hello', recipient: @recipient %> + +

+ <%= t('.body.remember', + REFERENCE: @attached_object.payment_schedule.reference, + AMOUNT: number_to_currency(@attached_object.amount), + DATE: I18n.l @attached_object.due_date, format: :long) %> + <%= t('.body.error') %> +

+

<%= t('.body.action') %>

diff --git a/app/workers/payment_schedule_item_worker.rb b/app/workers/payment_schedule_item_worker.rb index decb3cc21..091574dda 100644 --- a/app/workers/payment_schedule_item_worker.rb +++ b/app/workers/payment_schedule_item_worker.rb @@ -9,23 +9,27 @@ class PaymentScheduleItemWorker PaymentScheduleItem.where(due_date: [DateTime.current.at_beginning_of_day, DateTime.current.end_of_day], state: 'new').each do |psi| # the following depends on the payment method (stripe/check) if psi.payment_schedule.payment_method == 'stripe' + ### Stripe stripe_key = Setting.get('stripe_secret_key') stp_suscription = Stripe::Subscription.retrieve(psi.payment_schedule.stp_subscription_id, api_key: stripe_key) stp_invoice = Stripe::Invoice.retrieve(stp_suscription.latest_invoice, api_key: stripe_key) if stp_invoice.status == 'paid' + ##### Stripe / Successfully paid PaymentScheduleService.new.generate_invoice(psi, stp_invoice) psi.update_attributes(state: 'paid') else - NotificationCenter.call type: 'notify_admin_payment_schedule_failed', # TODO + ##### Stripe / Payment error + NotificationCenter.call type: 'notify_admin_payment_schedule_failed', receiver: User.admins_and_managers, attached_object: psi - NotificationCenter.call type: 'notify_member_payment_schedule_failed', # TODO + NotificationCenter.call type: 'notify_member_payment_schedule_failed', receiver: psi.payment_schedule.user, attached_object: psi psi.update_attributes(state: 'pending') end else - NotificationCenter.call type: 'notify_admin_payment_schedule_check', # TODO + ### Check + NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline', receiver: User.admins_and_managers, attached_object: psi psi.update_attributes(state: 'pending') diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 1b7dcffa1..1f0f641b6 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -359,6 +359,8 @@ en: validity_per_user: "Validity per user" once: "Just once" forever: "Each use" + warn_validity_once: "Please note that when this coupon will be used with a payment schedule, the discount will be applied to the first deadline only." + warn_validity_forever: "Please note that when this coupon will be used with a payment schedule, the discount will be applied to each deadlines." validity_per_user_is_required: "Validity per user is required." valid_until: "Valid until (included)" leave_empty_for_no_limit: "Do not specify any limit by leaving the field empty." diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 2d95077a2..1adc6214e 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -359,6 +359,8 @@ fr: validity_per_user: "Validité par utilisateur" once: "Une seule fois" forever: "À chaque utilisation" + warn_validity_once: "Veuillez noter que lors de l'utilisation de ce code promo avec un échéancier de paiement, la réduction sera appliquée uniquement à la première échéance." + warn_validity_forever: "Veuillez noter que lors de l'utilisation de ce code promo avec un échéancier de paiement, la réduction sera appliquée à chaque échéance." validity_per_user_is_required: "La validité par utilisateur est requise." valid_until: "Valable jusqu'au (inclus)" leave_empty_for_no_limit: "Laissez vide pour ne pas spécifier de limite." diff --git a/config/locales/en.yml b/config/locales/en.yml index c4aaad9ef..92a0d4086 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -358,6 +358,12 @@ en: all_members_sync: "All members were successfully synchronized on Stripe." notify_user_when_payment_schedule_ready: your_schedule_is_ready_html: "Your payment schedule #%{REFERENCE}, of %{AMOUNT}, is ready. Click here to download." + notify_admin_payment_schedule_failed: + schedule_failed: "Failed card debit for the %{DATE} deadline, for schedule %{REFERENCE}" + notify_member_payment_schedule_failed: + schedule_failed: "Failed card debit for the %{DATE} deadline, for your schedule %{REFERENCE}" + notify_admin_payment_schedule_check_deadline: + schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" #statistics tools for admins statistics: subscriptions: "Subscriptions" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 8c3c340d1..467548b64 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -358,6 +358,12 @@ fr: all_members_sync: "Tous les membres ont été synchronisés avec succès sur Stripe." notify_user_when_payment_schedule_ready: your_schedule_is_ready_html: "Votre échéancier n°%{REFERENCE}, d'un montant de %{AMOUNT}, est prêt. Cliquez ici pour le télécharger." + notify_admin_payment_schedule_failed: + schedule_failed: "Échec du prélèvement par carte de l'échéance du %{DATE}, pour l'échéancier %{REFERENCE}" + notify_member_payment_schedule_failed: + schedule_failed: "Échec du prélèvement par carte de l'échéance du %{DATE}, pour votre échéancier %{REFERENCE}" + notify_admin_payment_schedule_check_deadline: + schedule_deadline: "Vous devez encaisser le chèque de l'échéance du %{DATE}, pour l'échéancier %{REFERENCE}" #statistics tools for admins statistics: subscriptions: "Abonnements" diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 1cc3fa30a..dbbca7387 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -293,5 +293,23 @@ en: please_find_attached_html: "Please find attached your payment schedule, issued on {DATE}, with an amount of {AMOUNT} concerning your {TYPE, select, Reservation{reservation} other{subscription}}." #messageFormat interpolation schedule_in_your_dashboard_html: "You can find this payment schedule at any time from %{DASHBOARD} on the Fab Lab's website." your_dashboard: "your dashboard" + notify_admin_payment_schedule_failed: + subject: "Card debit failure" + body: + remember: "In accordance with the %{REFERENCE} payment schedule, a debit by card of %{AMOUNT} was scheduled on %{DATE}." + error: "Unfortunately, this card debit was unable to complete successfully." + action: "Please go to your payment schedule management interface as soon as possible to resolve the problem." + notify_member_payment_schedule_failed: + subject: "Card debit failure" + body: + remember: "In accordance with your %{REFERENCE} payment schedule, a debit by card of %{AMOUNT} was scheduled on %{DATE}." + error: "Unfortunately, this card debit was unable to complete successfully." + action: "Please contact the manager of your FabLab as soon as possible, otherwise your subscription may be interrupted." + notify_admin_payment_schedule_check_deadline: + subject: "Payment deadline" + body: + remember: "In accordance with the %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}." + date: "This is a reminder to cash the scheduled check as soon as possible." + confirm: "Do not forget to confirm the receipt in your payment schedule management interface, so that the corresponding invoice will be generated." shared: hello: "Hello %{user_name}" diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index 34b636b92..00c8c85a3 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -293,5 +293,23 @@ fr: please_find_attached_html: "Vous trouverez en pièce jointe votre échéancier de paiement du {DATE}, d'un montant de {AMOUNT} concernant votre {TYPE, select, Reservation{réservation} other{abonnement}}." #messageFormat interpolation schedule_in_your_dashboard_html: "Vous pouvez à tout moment retrouver votre échéancier dans %{DASHBOARD} sur le site du Fab Lab." your_dashboard: "votre tableau de bord" + notify_admin_payment_schedule_failed: + subject: "Échec du prélèvement par carte" + body: + remember: "Conformément à l'échéancier de paiement %{REFERENCE}, un prélèvement par carte de %{AMOUNT} était prévu le %{DATE}." + error: "Malheureusement, ce prélèvement n'a pas pu être effectué correctement." + action: "Veuillez vous rendre au plus tôt dans votre interface de gestion des échéanciers pour régler le problème." + notify_member_payment_schedule_failed: + subject: "Échec du prélèvement par carte" + body: + remember: "Conformément à votre échéancier de paiement %{REFERENCE}, un prélèvement par carte de %{AMOUNT} était prévu le %{DATE}." + error: "Malheureusement, ce prélèvement n'a pas pu être effectué correctement." + action: "Veuillez prendre contact avec le gestionnaire de votre FabLab au plus tôt, faute de quoi votre abonnement risque d'être interrompu." + notify_admin_payment_schedule_check_deadline: + subject: "Échéance d'encaissement" + body: + remember: "Conformément à l'échéancier de paiement %{REFERENCE}, une échéance de %{AMOUNT} était prévu pour être prélevée le %{DATE}." + date: "Ceci est un rappel d'encaisser le chèque prévu dès que possible." + confirm: "N'oubliez pas de confirmer l'encaissement dans votre interface de gestion des échéanciers, afin que la facture correspondante soit générée." shared: hello: "Bonjour %{user_name}"