From 58af40b8b98dd10626d4fad32c476c8cbdbdb79d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 8 Feb 2021 08:56:01 +0100 Subject: [PATCH 1/5] enlight api for cashing checks --- app/controllers/api/payment_schedules_controller.rb | 8 ++++---- app/frontend/src/javascript/api/payment-schedule.ts | 8 ++++++-- .../src/javascript/components/payment-schedules-table.tsx | 8 +++++--- app/frontend/src/javascript/models/payment-schedule.ts | 5 +++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index defb6fd2f..e49780058 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -27,12 +27,12 @@ class API::PaymentSchedulesController < API::ApiController end def cash_check - schedule = @payment_schedule_item.payment_schedule - authorize schedule + authorize @payment_schedule_item.payment_schedule PaymentScheduleService.new.generate_invoice(@payment_schedule_item) - @payment_schedule_item.update_attributes(state: 'paid', payment_method: 'check') + attrs = { state: 'paid', payment_method: 'check' } + @payment_schedule_item.update_attributes(attrs) - render :show, status: :ok, location: schedule + render json: attrs, status: :ok end private diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index e149927e9..c933da110 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -1,6 +1,10 @@ import apiClient from './api-client'; import { AxiosResponse } from 'axios'; -import { PaymentSchedule, PaymentScheduleIndexRequest, PaymentScheduleItem } from '../models/payment-schedule'; +import { + CashCheckResponse, + PaymentSchedule, + PaymentScheduleIndexRequest, +} from '../models/payment-schedule'; import wrapPromise, { IWrapPromise } from '../lib/wrap-promise'; export default class PaymentScheduleAPI { @@ -9,7 +13,7 @@ export default class PaymentScheduleAPI { return res?.data; } - async cashCheck(paymentScheduleItemId: number) { + async cashCheck(paymentScheduleItemId: number): Promise { const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/cash_check`); return res?.data; } diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedules-table.tsx index 9257be8b6..c99d4e30c 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -158,9 +158,11 @@ const PaymentSchedulesTableComponent: React.FC = ({ const onCheckCashingConfirmed = (): void => { const api = new PaymentScheduleAPI(); - api.cashCheck(tempDeadline.id).then(() => { - refreshList(); - toggleConfirmCashingModal(); + api.cashCheck(tempDeadline.id).then((res) => { + if (res.state === PaymentScheduleItemState.Paid) { + refreshList(); + toggleConfirmCashingModal(); + } }); } diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index f496e15e7..4abe50d55 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -58,3 +58,8 @@ export interface PaymentScheduleIndexRequest { size: number } } + +export interface CashCheckResponse { + state: PaymentScheduleItemState, + payment_method: PaymentMethod +} From 76ebb3ccb4806c6d08659f999739e60f4da386ca Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 8 Feb 2021 10:05:54 +0100 Subject: [PATCH 2/5] run the PaymentScheduleItemWorker more ofter and tell the users that they ahev only 24h to fix the payment --- app/workers/payment_schedule_item_worker.rb | 2 +- config/locales/mails.en.yml | 8 ++++---- config/locales/mails.fr.yml | 8 ++++---- config/schedule.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/workers/payment_schedule_item_worker.rb b/app/workers/payment_schedule_item_worker.rb index a77cb98ab..002bae6a6 100644 --- a/app/workers/payment_schedule_item_worker.rb +++ b/app/workers/payment_schedule_item_worker.rb @@ -6,7 +6,7 @@ class PaymentScheduleItemWorker include Sidekiq::Worker def perform - PaymentScheduleItem.where(state: 'new').where('due_date < ?', DateTime.current.end_of_day).each do |psi| + PaymentScheduleItem.where(state: 'new').where('due_date < ?', DateTime.current).each do |psi| # the following depends on the payment method (stripe/check) if psi.payment_schedule.payment_method == 'stripe' ### Stripe diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index dbbca7387..395c472b7 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -294,17 +294,17 @@ en: 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" + subject: "[URGENT] 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." + action: "Please contact the member as soon as possible, and go to your payment schedule management interface to resolve the problem. After about 24 hours, the card subscription will be cancelled." notify_member_payment_schedule_failed: - subject: "Card debit failure" + subject: "[URGENT] 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." + action: "Please contact the manager of your FabLab before 24 hours, otherwise your subscription may be interrupted." notify_admin_payment_schedule_check_deadline: subject: "Payment deadline" body: diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index 00c8c85a3..381ceceb1 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -294,17 +294,17 @@ fr: 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" + subject: "[URGENT] É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." + action: "Veuillez vous mettre en relation avec le membre au plus tôt, ainsi que vous rendre dans votre interface de gestion des échéanciers pour régler le problème. Au delà d'environ 24 heures, l'abonnement par carte bancaire sera annulé." notify_member_payment_schedule_failed: - subject: "Échec du prélèvement par carte" + subject: "[URGENT] É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." + action: "Veuillez prendre contact avec le gestionnaire de votre FabLab sous 24 heures, faute de quoi votre abonnement risque d'être interrompu." notify_admin_payment_schedule_check_deadline: subject: "Échéance d'encaissement" body: diff --git a/config/schedule.yml b/config/schedule.yml index d1e1b9ca4..af0ecf6b4 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -48,7 +48,7 @@ version_check: queue: system payment_schedule_item: - cron: "0 23 * * *" # every day at 11pm + cron: "0 * * * *" # every day, every hour class: 'PaymentScheduleItemWorker' queue: default From 38ac55c35fb3ac6d9fd5b219d1a1649a45ea03d9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 8 Feb 2021 13:50:49 +0100 Subject: [PATCH 3/5] [bug] no notifications for refunds generated on wallet credit --- CHANGELOG.md | 1 + .../notify_admin_refund_created.html.erb | 5 ++++- config/locales/mails.en.yml | 1 + config/locales/mails.fr.yml | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f966a8a..30a0c9f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix a bug: warning message overflow in credit wallet modal - Fix a bug: when using a cash coupon, the amount shown in the statistics is invalid - Fix a bug: unable to create a coupon on stripe +- Fix a bug: no notifications for refunds generated on wallet credit - [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet` - [TODO DEPLOY] `rails fablab:stripe:set_product_id` - [TODO DEPLOY] `rails fablab:setup:add_schedule_reference` diff --git a/app/views/notifications_mailer/notify_admin_refund_created.html.erb b/app/views/notifications_mailer/notify_admin_refund_created.html.erb index d0ec9e8ad..300cb54fe 100644 --- a/app/views/notifications_mailer/notify_admin_refund_created.html.erb +++ b/app/views/notifications_mailer/notify_admin_refund_created.html.erb @@ -3,6 +3,9 @@

<%= t('.body.refund_created', AMOUNT: number_to_currency(@attached_object.total / 100.00), INVOICE: @attached_object.invoice.reference, - USER: @attached_object.invoicing_profile&.full_name) %> + USER: @attached_object.invoicing_profile&.full_name) if @attached_object.invoice %> + <%= t('.body.wallet_refund_created', + AMOUNT: number_to_currency(@attached_object.total / 100.00), + USER: @attached_object.invoicing_profile&.full_name) if @attached_object.invoiced_type === WalletTransaction.name %>

" target="_blank"><%= t('.body.download') %>

diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 395c472b7..2580d9859 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -272,6 +272,7 @@ en: subject: "A refund has been generated" body: refund_created: "A refund of %{AMOUNT} has been generated on invoice %{INVOICE} of user %{USER}" + wallet_refund_created: "A refund of %{AMOUNT} has been generated for the credit of the wallet of user %{USER}" download: "Click here to download this refund invoice" notify_admins_role_update: subject: "The role of a user has changed" diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index 381ceceb1..8bbb7c850 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -272,6 +272,7 @@ fr: subject: "Un avoir a été généré" body: refund_created: "Un avoir de %{AMOUNT} a été généré sur la facture %{INVOICE} de l'utilisateur %{USER}" + wallet_refund_created: "Un avoir de %{AMOUNT} a été généré pour le crédit du porte-monnaie de l'utilisateur %{USER}" download: "Cliquez ici pour télécharger cet avoir" notify_admins_role_update: subject: "Le rôle d'un utilisateur a changé" From 0e7226c85cd195f10ac0ea6be389774c46727093 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 8 Feb 2021 15:28:47 +0100 Subject: [PATCH 4/5] handle stripe requires_action --- .../api/payment_schedules_controller.rb | 9 ++- .../src/javascript/api/payment-schedule.ts | 5 ++ .../src/javascript/components/fab-button.tsx | 5 +- .../src/javascript/components/fab-modal.tsx | 9 ++- .../components/payment-schedules-table.tsx | 80 +++++++++++++------ .../javascript/components/stripe-confirm.tsx | 34 ++++++++ .../src/javascript/models/payment-schedule.ts | 6 +- app/frontend/src/stylesheets/application.scss | 1 + .../src/stylesheets/modules/fab-button.scss | 9 +++ .../stylesheets/modules/stripe-confirm.scss | 41 ++++++++++ app/models/coupon.rb | 2 +- app/models/payment_schedule_item.rb | 2 +- app/policies/payment_schedule_policy.rb | 4 + app/services/payment_schedule_service.rb | 6 +- app/themes/casemate/style.scss.erb | 17 +++- ...n_payment_schedule_check_deadline.html.erb | 4 +- ...ify_admin_payment_schedule_failed.html.erb | 4 +- .../notify_admin_slot_is_canceled.html.erb | 2 +- .../notify_admin_slot_is_modified.html.erb | 4 +- ...fy_member_payment_schedule_failed.html.erb | 6 +- .../notify_member_slot_is_canceled.html.erb | 2 +- .../notify_member_slot_is_modified.html.erb | 4 +- app/workers/payment_schedule_item_worker.rb | 73 ++++++++++------- config/locales/app.admin.en.yml | 2 + config/locales/app.admin.fr.yml | 2 + config/locales/app.shared.en.yml | 4 + config/locales/app.shared.fr.yml | 4 + config/locales/mails.en.yml | 5 +- config/locales/mails.fr.yml | 5 +- config/routes.rb | 1 + ...027101809_create_payment_schedule_items.rb | 1 + db/structure.sql | 1 + 32 files changed, 268 insertions(+), 86 deletions(-) create mode 100644 app/frontend/src/javascript/components/stripe-confirm.tsx create mode 100644 app/frontend/src/stylesheets/modules/stripe-confirm.scss diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index e49780058..4b70bd765 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -4,7 +4,7 @@ class API::PaymentSchedulesController < API::ApiController before_action :authenticate_user! before_action :set_payment_schedule, only: %i[download] - before_action :set_payment_schedule_item, only: %i[cash_check] + before_action :set_payment_schedule_item, only: %i[cash_check refresh_item] def list authorize PaymentSchedule @@ -35,6 +35,13 @@ class API::PaymentSchedulesController < API::ApiController render json: attrs, status: :ok end + def refresh_item + authorize @payment_schedule_item.payment_schedule + PaymentScheduleItemWorker.new.perform(params[:id]) + + render json: { state: 'refreshed' }, status: :ok + end + private def set_payment_schedule diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index c933da110..f87e8f41e 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -18,6 +18,11 @@ export default class PaymentScheduleAPI { return res?.data; } + async refreshItem(paymentScheduleItemId: number): Promise { + const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`); + return res?.data; + } + static list (query: PaymentScheduleIndexRequest): IWrapPromise> { const api = new PaymentScheduleAPI(); return wrapPromise(api.list(query)); diff --git a/app/frontend/src/javascript/components/fab-button.tsx b/app/frontend/src/javascript/components/fab-button.tsx index 86b8e47eb..d87ec74fe 100644 --- a/app/frontend/src/javascript/components/fab-button.tsx +++ b/app/frontend/src/javascript/components/fab-button.tsx @@ -8,10 +8,11 @@ interface FabButtonProps { onClick?: (event: SyntheticEvent) => void, icon?: ReactNode, className?: string, + disabled?: boolean, } -export const FabButton: React.FC = ({ onClick, icon, className, children }) => { +export const FabButton: React.FC = ({ onClick, icon, className, disabled, children }) => { /** * Check if the current component was provided an icon to display */ @@ -29,7 +30,7 @@ export const FabButton: React.FC = ({ onClick, icon, className, } return ( - diff --git a/app/frontend/src/javascript/components/fab-modal.tsx b/app/frontend/src/javascript/components/fab-modal.tsx index caf4dd67c..0f1d18a41 100644 --- a/app/frontend/src/javascript/components/fab-modal.tsx +++ b/app/frontend/src/javascript/components/fab-modal.tsx @@ -27,12 +27,13 @@ interface FabModalProps { className?: string, width?: ModalSize, customFooter?: ReactNode, - onConfirm?: (event: SyntheticEvent) => void + onConfirm?: (event: SyntheticEvent) => void, + preventConfirm?: boolean } const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile); -export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm }) => { +export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => { const { t } = useTranslation('shared'); const blackLogo = blackLogoFile.read(); @@ -58,7 +59,7 @@ export const FabModal: React.FC = ({ title, isOpen, toggleModal, } return ( - @@ -76,7 +77,7 @@ export const FabModal: React.FC = ({ title, isOpen, toggleModal,
{hasCloseButton() &&{t('app.shared.buttons.close')}} - {hasConfirmButton() && {confirmButton}} + {hasConfirmButton() && {confirmButton}} {hasCustomFooter() && customFooter}
diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedules-table.tsx index c99d4e30c..87bd28fbb 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -12,6 +12,8 @@ import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from ' import { FabButton } from './fab-button'; import { FabModal } from './fab-modal'; import PaymentScheduleAPI from '../api/payment-schedule'; +import { StripeElements } from './stripe-elements'; +import { StripeConfirm } from './stripe-confirm'; declare var Fablab: IFablab; @@ -26,6 +28,8 @@ const PaymentSchedulesTableComponent: React.FC = ({ const [showExpanded, setShowExpanded] = useState>(new Map()); const [showConfirmCashing, setShowConfirmCashing] = useState(false); + const [showResolveAction, setShowResolveAction] = useState(false); + const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); const [tempDeadline, setTempDeadline] = useState(null); /** @@ -149,6 +153,9 @@ const PaymentSchedulesTableComponent: React.FC = ({ } } + /** + * Callback triggered when the user's clicks on the "cash check" button: show a confirmation modal + */ const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => { return (): void => { setTempDeadline(item); @@ -156,6 +163,9 @@ const PaymentSchedulesTableComponent: React.FC = ({ } } + /** + * After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal. + */ const onCheckCashingConfirmed = (): void => { const api = new PaymentScheduleAPI(); api.cashCheck(tempDeadline.id).then((res) => { @@ -174,37 +184,44 @@ const PaymentSchedulesTableComponent: React.FC = ({ } /** - * Dynamically build the content of the modal depending on the currently selected deadline + * Show/hide the modal dialog that trigger the card "action". */ - const cashingModalContent = (): ReactNode => { - if (tempDeadline) { - return ( - {t('app.admin.invoices.schedules_table.confirm_check_cashing_body', { - AMOUNT: formatPrice(tempDeadline.amount), - DATE: formatDate(tempDeadline.due_date) - })} - ); - } - - return ; + const toggleResolveActionModal = (): void => { + setShowResolveAction(!showResolveAction); } + /** + * Callback triggered when the user's clicks on the "resolve" button: show a modal that will trigger the action + */ const handleSolveAction = (item: PaymentScheduleItem): ReactEventHandler => { return (): void => { - /* - TODO - - create component wrapped with - - stripe.confirmCardSetup(item.client_secret).then(function(result) { - if (result.error) { - // Display error.message in your UI. - } else { - // The setup has succeeded. Display a success message. - } - }); - */ + setTempDeadline(item); + toggleResolveActionModal(); } } + /** + * After the action was done (successfully or not), ask the API to refresh the item status, then refresh the list and close the modal + */ + const afterAction = (): void => { + toggleConfirmActionButton(); + const api = new PaymentScheduleAPI(); + api.refreshItem(tempDeadline.id).then(() => { + refreshList(); + toggleResolveActionModal(); + }); + } + + /** + * Enable/disable the confirm button of the "action" modal + */ + const toggleConfirmActionButton = (): void => { + setConfirmActionDisabled(!isConfirmActionDisabled); + } + + /** + * Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card + */ const handleUpdateCard = (item: PaymentScheduleItem): ReactEventHandler => { return (): void => { /* @@ -281,8 +298,23 @@ const PaymentSchedulesTableComponent: React.FC = ({ onConfirm={onCheckCashingConfirmed} closeButton={true} confirmButton={t('app.admin.invoices.schedules_table.confirm_button')}> - {cashingModalContent()} + {tempDeadline && + {t('app.admin.invoices.schedules_table.confirm_check_cashing_body', { + AMOUNT: formatPrice(tempDeadline.amount), + DATE: formatDate(tempDeadline.due_date) + })} + } + + + {tempDeadline && } + + ); diff --git a/app/frontend/src/javascript/components/stripe-confirm.tsx b/app/frontend/src/javascript/components/stripe-confirm.tsx new file mode 100644 index 000000000..4064aa3b4 --- /dev/null +++ b/app/frontend/src/javascript/components/stripe-confirm.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import { useStripe } from '@stripe/react-stripe-js'; +import { useTranslation } from 'react-i18next'; + +interface StripeConfirmProps { + clientSecret: string, + onResponse: () => void, +} + +export const StripeConfirm: React.FC = ({ clientSecret, onResponse }) => { + const stripe = useStripe(); + const { t } = useTranslation('shared'); + + const [message, setMessage] = useState(t('app.shared.stripe_confirm.pending')); + const [type, setType] = useState('info'); + + useEffect(() => { + stripe.confirmCardPayment(clientSecret).then(function(result) { + onResponse(); + if (result.error) { + // Display error.message in your UI. + setType('error'); + setMessage(result.error.message); + } else { + // The setup has succeeded. Display a success message. + setType('success'); + setMessage(t('app.shared.stripe_confirm.success')); + } + }); + }, []) + return
+
{message}
+
; +} diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index 4abe50d55..ce8c6d6e8 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -21,8 +21,10 @@ export interface PaymentScheduleItem { client_secret?: string, details: { recurring: number, - adjustment: number, - other_items: number + adjustment?: number, + other_items?: number, + without_coupon?: number, + subscription_id: number } } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 2c2118252..2c16f03eb 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -30,5 +30,6 @@ @import "modules/document-filters"; @import "modules/payment-schedules-table"; @import "modules/payment-schedules-list"; +@import "modules/stripe-confirm"; @import "app.responsive"; diff --git a/app/frontend/src/stylesheets/modules/fab-button.scss b/app/frontend/src/stylesheets/modules/fab-button.scss index f9a151fd9..68cd97f54 100644 --- a/app/frontend/src/stylesheets/modules/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/fab-button.scss @@ -33,6 +33,15 @@ box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); } + + &[disabled] { + color: #3a3a3a; + } + + &[disabled]:hover { + color: #3a3a3a; + } + &--icon { margin-right: 0.5em; } diff --git a/app/frontend/src/stylesheets/modules/stripe-confirm.scss b/app/frontend/src/stylesheets/modules/stripe-confirm.scss new file mode 100644 index 000000000..cb7a3da4c --- /dev/null +++ b/app/frontend/src/stylesheets/modules/stripe-confirm.scss @@ -0,0 +1,41 @@ +@keyframes spin { 100% { transform:rotate(360deg); } } + +.stripe-confirm { + .message { + &--success:before { + font-family: 'Font Awesome 5 Free'; + font-weight: 900; + content: "\f00c"; + color: #3c763d; + margin-right: 0.5em; + } + + &--error:before { + font-family: 'Font Awesome 5 Free'; + font-weight: 900; + content: "\f00d"; + color: #840b0f; + margin-right: 0.5em; + } + + &--info:before { + font-family: 'Font Awesome 5 Free'; + font-weight: 900; + content: "\f1ce"; + color: #a0a0a0; + margin-right: 2em; + animation:spin 2s linear infinite; + position: absolute; + } + + &--info { + .message-text { + margin-left: 1.5em; + } + } + } + + .message-text { + margin-left: 0.5em; + } +} diff --git a/app/models/coupon.rb b/app/models/coupon.rb index e8f0194ae..04650e924 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -40,7 +40,7 @@ class Coupon < ApplicationRecord end def usages - invoices.count + payment_schedule.count + invoices.count end ## diff --git a/app/models/payment_schedule_item.rb b/app/models/payment_schedule_item.rb index 45f4f2a46..4e49d6cbd 100644 --- a/app/models/payment_schedule_item.rb +++ b/app/models/payment_schedule_item.rb @@ -19,6 +19,6 @@ class PaymentScheduleItem < Footprintable end def self.columns_out_of_footprint - %w[invoice_id stp_invoice_id state payment_method] + %w[invoice_id stp_invoice_id state payment_method client_secret] end end diff --git a/app/policies/payment_schedule_policy.rb b/app/policies/payment_schedule_policy.rb index a7a919480..eb5fc9916 100644 --- a/app/policies/payment_schedule_policy.rb +++ b/app/policies/payment_schedule_policy.rb @@ -10,6 +10,10 @@ class PaymentSchedulePolicy < ApplicationPolicy user.admin? || user.manager? end + def refresh_item? + user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id) + end + def download? user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id) end diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index a40f9a79a..46963059a 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -142,7 +142,7 @@ class PaymentScheduleService ## # The first PaymentScheduleItem contains references to the reservation price (if any) and to the adjustement price - # for the subscription (if any) + # for the subscription (if any) and the wallet transaction (if any) ## def complete_first_invoice(payment_schedule_item, invoice) # sub-prices for the subscription and the reservation @@ -157,6 +157,10 @@ class PaymentScheduleService reservation = payment_schedule_item.payment_schedule.scheduled end + # the wallet transaction + invoice[:wallet_amount] = payment_schedule_item.payment_schedule.wallet_amount + invoice[:wallet_transaction_id] = payment_schedule_item.payment_schedule.wallet_transaction_id + # build the invoice items generate_invoice_items(invoice, details, subscription: subscription, reservation: reservation) end diff --git a/app/themes/casemate/style.scss.erb b/app/themes/casemate/style.scss.erb index 83106afdb..0fed49479 100644 --- a/app/themes/casemate/style.scss.erb +++ b/app/themes/casemate/style.scss.erb @@ -4,6 +4,7 @@ $primary-dark: <%= Stylesheet.primary_dark %> !default; $secondary: <%= Stylesheet.secondary %> !default; $secondary-light: <%= Stylesheet.secondary_light %> !default; +$secondary-lighter: lighten(<%= Stylesheet.secondary_light %>, 20%) !default; $secondary-dark: <%= Stylesheet.secondary_dark %> !default; $primary-text-color: <%= Stylesheet.primary_text_color %> !default; @@ -320,10 +321,22 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept { } &:hover { - background-color: $secondary-dark !important; - border-color: $secondary-dark !important; + background-color: $secondary-dark; + border-color: $secondary-dark; color: $secondary-text-color; } + + &[disabled] { + background-color: $secondary-light; + color: $secondary-lighter; + border-color: $secondary-lighter; + } + + &[disabled]:hover { + background-color: $secondary-light; + color: $secondary-lighter; + border-color: $secondary-lighter; + } } } } 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 index e57d228fd..5cdc9b065 100644 --- 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 @@ -3,8 +3,8 @@

<%= 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) %> + AMOUNT: number_to_currency(@attached_object.amount / 100.00), + 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 index 2262cb8f0..57eb813ba 100644 --- a/app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb +++ b/app/views/notifications_mailer/notify_admin_payment_schedule_failed.html.erb @@ -3,8 +3,8 @@

<%= 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) %> + AMOUNT: number_to_currency(@attached_object.amount / 100.00), + DATE: I18n.l(@attached_object.due_date, format: :long)) %> <%= t('.body.error') %>

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

diff --git a/app/views/notifications_mailer/notify_admin_slot_is_canceled.html.erb b/app/views/notifications_mailer/notify_admin_slot_is_canceled.html.erb index e630c8011..e1b802385 100644 --- a/app/views/notifications_mailer/notify_admin_slot_is_canceled.html.erb +++ b/app/views/notifications_mailer/notify_admin_slot_is_canceled.html.erb @@ -3,7 +3,7 @@

<%= t('.body.member_cancelled', NAME: @attached_object.reservation.user&.profile&.full_name || t('api.notifications.deleted_user')) %>

<%= t('.body.item_details', START: I18n.l(@attached_object.start_at, format: :long), - END:(I18n.l @attached_object.end_at, format: :hour_minute), + END: I18n.l(@attached_object.end_at, format: :hour_minute), RESERVABLE: @attached_object.reservation.reservable.name) %>

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

diff --git a/app/views/notifications_mailer/notify_admin_slot_is_modified.html.erb b/app/views/notifications_mailer/notify_admin_slot_is_modified.html.erb index 2aa39e399..603efe0af 100644 --- a/app/views/notifications_mailer/notify_admin_slot_is_modified.html.erb +++ b/app/views/notifications_mailer/notify_admin_slot_is_modified.html.erb @@ -1,5 +1,5 @@ <%= render 'notifications_mailer/shared/hello', recipient: @recipient %>

<%= t('.body.slot_modified', NAME: @attached_object.reservation.user&.profile&.full_name || t('api.notifications.deleted_user')) %>

-

<%= t('.body.new_date') %> <%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %>

-

<%= t('.body.old_date') %> <%= "#{I18n.l @attached_object.ex_start_at, format: :long} - #{I18n.l @attached_object.ex_end_at, format: :hour_minute}" %>

+

<%= t('.body.new_date') %> <%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %>

+

<%= t('.body.old_date') %> <%= "#{I18n.l(@attached_object.ex_start_at, format: :long)} - #{I18n.l(@attached_object.ex_end_at, format: :hour_minute)}" %>

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 index 2262cb8f0..a1c6639e5 100644 --- a/app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb +++ b/app/views/notifications_mailer/notify_member_payment_schedule_failed.html.erb @@ -3,8 +3,8 @@

<%= 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) %> + AMOUNT: number_to_currency(@attached_object.amount / 100.00), + DATE: I18n.l(@attached_object.due_date, format: :long)) %> <%= t('.body.error') %>

-

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

+

<%= t('.body.action', DASHBOARD: link_to(t('.body.your_dashboard'), "#{root_url}#!/dashboard/invoices")) %>

diff --git a/app/views/notifications_mailer/notify_member_slot_is_canceled.html.erb b/app/views/notifications_mailer/notify_member_slot_is_canceled.html.erb index 2170f508e..89808e6b6 100644 --- a/app/views/notifications_mailer/notify_member_slot_is_canceled.html.erb +++ b/app/views/notifications_mailer/notify_member_slot_is_canceled.html.erb @@ -1,4 +1,4 @@ <%= render 'notifications_mailer/shared/hello', recipient: @recipient %>

<%= t('.body.reservation_canceled', RESERVABLE: @attached_object.reservation.reservable.name ) %>

-

<%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %>

+

<%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %>

diff --git a/app/views/notifications_mailer/notify_member_slot_is_modified.html.erb b/app/views/notifications_mailer/notify_member_slot_is_modified.html.erb index 6e055aecc..bb53fad99 100644 --- a/app/views/notifications_mailer/notify_member_slot_is_modified.html.erb +++ b/app/views/notifications_mailer/notify_member_slot_is_modified.html.erb @@ -1,5 +1,5 @@ <%= render 'notifications_mailer/shared/hello', recipient: @recipient %>

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

-

<%= "#{I18n.l @attached_object.start_at, format: :long} - #{I18n.l @attached_object.end_at, format: :hour_minute}" %>

-

<%= t('.body.previous_date') %> <%= "#{I18n.l @attached_object.ex_start_at, format: :long} - #{I18n.l @attached_object.ex_end_at, format: :hour_minute}" %>

+

<%= "#{I18n.l(@attached_object.start_at, format: :long)} - #{I18n.l(@attached_object.end_at, format: :hour_minute)}" %>

+

<%= t('.body.previous_date') %> <%= "#{I18n.l(@attached_object.ex_start_at, format: :long)} - #{I18n.l(@attached_object.ex_end_at, format: :hour_minute)}" %>

diff --git a/app/workers/payment_schedule_item_worker.rb b/app/workers/payment_schedule_item_worker.rb index 002bae6a6..3f27d0c53 100644 --- a/app/workers/payment_schedule_item_worker.rb +++ b/app/workers/payment_schedule_item_worker.rb @@ -5,38 +5,49 @@ class PaymentScheduleItemWorker include Sidekiq::Worker - def perform - PaymentScheduleItem.where(state: 'new').where('due_date < ?', DateTime.current).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', payment_method: 'stripe', stp_invoice_id: stp_invoice.id) - elsif stp_suscription.status == 'past_due' - ##### 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', - receiver: psi.payment_schedule.user, - attached_object: psi - stp_payment_intent = Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: stripe_key) - psi.update_attributes(state: stp_payment_intent.status, stp_invoice_id: stp_invoice.id) - else - psi.update_attributes(state: 'error') - end - else - ### Check - NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline', - receiver: User.admins_and_managers, - attached_object: psi - psi.update_attributes(state: 'pending') + def perform(record_id = nil) + if record_id + psi = PaymentScheduleItem.find(record_id) + check_item(psi) + else + PaymentScheduleItem.where.not(state: 'paid').where('due_date < ?', DateTime.current).each do |psi| + check_item(psi) end end end + + def check_item(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_subscription = Stripe::Subscription.retrieve(psi.payment_schedule.stp_subscription_id, api_key: stripe_key) + stp_invoice = Stripe::Invoice.retrieve(stp_subscription.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', payment_method: 'stripe', stp_invoice_id: stp_invoice.id) + elsif stp_subscription.status == 'past_due' || stp_invoice.status == 'open' + ##### 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', + receiver: psi.payment_schedule.user, + attached_object: psi + stp_payment_intent = Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: stripe_key) + psi.update_attributes(state: stp_payment_intent.status, + stp_invoice_id: stp_invoice.id, + client_secret: stp_payment_intent.client_secret) + else + psi.update_attributes(state: 'error') + end + else + ### Check + NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline', + receiver: User.admins_and_managers, + attached_object: psi + psi.update_attributes(state: 'pending') + end + end end diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 8e3e1a83f..ac2399f6e 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -667,6 +667,8 @@ en: confirm_check_cashing: "Confirm the cashing of the check" confirm_check_cashing_body: "You must cash a check of {AMOUNT} for the deadline of {DATE}. By confirming the cashing of the check, an invoice will be generated for this due date." confirm_button: "Confirm" + resolve_action: "Resolve the action" + ok_button: "OK" document_filters: reference: "Reference" customer: "Customer" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 99a55b9c7..5cc962c73 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -667,6 +667,8 @@ fr: confirm_check_cashing: "Confirmer l'encaissement du chèque" confirm_check_cashing_body: "Vous devez encaisser un chèque de {AMOUNT} pour l'échéance du {DATE}. En confirmant l'encaissement du chèque, une facture sera générée pour cette échéance." confirm_button: "Confirmer" + resolve_action: "Résoudre l'action" + ok_button: "OK" document_filters: reference: "Référence" customer: "Client" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 1f0f641b6..758b955ce 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -474,3 +474,7 @@ en: what_to_do: "What do you want to do?" tour: "Start the feature tour" guide: "Open the user's manual" + # 2nd factor authentication for card payments + stripe_confirm: + pending: "Pending for action..." + success: "Thank you, your card setup is complete. The payment will be proceeded shortly." diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 1adc6214e..b4ab925f2 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -474,3 +474,7 @@ fr: what_to_do: "Que voulez-vous faire ?" tour: "Lancer la visite guidée" guide: "Ouvrir le manuel de l'utilisateur" + # 2nd factor authentication for card payments + stripe_confirm: + pending: "En attente de l'action ..." + success: "Merci, la configuration de votre carte est terminée. Le paiement sera effectué sous peu." diff --git a/config/locales/mails.en.yml b/config/locales/mails.en.yml index 2580d9859..d4183a4d6 100644 --- a/config/locales/mails.en.yml +++ b/config/locales/mails.en.yml @@ -299,13 +299,14 @@ en: 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 contact the member as soon as possible, and go to your payment schedule management interface to resolve the problem. After about 24 hours, the card subscription will be cancelled." + action: "Please contact the member as soon as possible, then go to the payment schedule management interface to resolve the problem. After about 24 hours, the card subscription will be cancelled." notify_member_payment_schedule_failed: subject: "[URGENT] 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 before 24 hours, otherwise your subscription may be interrupted." + action: "Please check %{DASHBOARD} or contact a manager before 24 hours, otherwise your subscription may be interrupted." + your_dashboard: "your dashboard" notify_admin_payment_schedule_check_deadline: subject: "Payment deadline" body: diff --git a/config/locales/mails.fr.yml b/config/locales/mails.fr.yml index 8bbb7c850..7460d3c08 100644 --- a/config/locales/mails.fr.yml +++ b/config/locales/mails.fr.yml @@ -299,13 +299,14 @@ fr: 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 mettre en relation avec le membre au plus tôt, ainsi que vous rendre dans votre interface de gestion des échéanciers pour régler le problème. Au delà d'environ 24 heures, l'abonnement par carte bancaire sera annulé." + action: "Veuillez vous mettre en relation avec le membre au plus tôt, puis vous rendre dans l'interface de gestion des échéanciers afin de régler le problème. Au delà d'environ 24 heures, l'abonnement par carte bancaire sera annulé." notify_member_payment_schedule_failed: subject: "[URGENT] É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 sous 24 heures, faute de quoi votre abonnement risque d'être interrompu." + action: "Veuillez vous rendre dans votre %{DASHBOARD} ou prendre contact avec un gestionnaire sous 24 heures, faute de quoi votre abonnement risque d'être interrompu." + your_dashboard: "votre tableau de bord" notify_admin_payment_schedule_check_deadline: subject: "Échéance d'encaissement" body: diff --git a/config/routes.rb b/config/routes.rb index f1f2bb44b..9929d27fe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,7 @@ Rails.application.routes.draw do post 'list', action: 'list', on: :collection get 'download', on: :member post 'items/:id/cash_check', action: 'cash_check', on: :collection + post 'items/:id/refresh_item', action: 'refresh_item', on: :collection end resources :i_calendar, only: %i[index create destroy] do diff --git a/db/migrate/20201027101809_create_payment_schedule_items.rb b/db/migrate/20201027101809_create_payment_schedule_items.rb index 8e4dfdb89..61c75419f 100644 --- a/db/migrate/20201027101809_create_payment_schedule_items.rb +++ b/db/migrate/20201027101809_create_payment_schedule_items.rb @@ -10,6 +10,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2] t.jsonb :details, default: '{}' t.string :stp_invoice_id t.string :payment_method + t.string :client_secret t.belongs_to :payment_schedule, foreign_key: true t.belongs_to :invoice, foreign_key: true t.string :footprint diff --git a/db/structure.sql b/db/structure.sql index 10a875218..718ee689b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1474,6 +1474,7 @@ CREATE TABLE public.payment_schedule_items ( details jsonb DEFAULT '"{}"'::jsonb, stp_invoice_id character varying, payment_method character varying, + client_secret character varying, payment_schedule_id bigint, invoice_id bigint, footprint character varying, From d1584604b37d88cc99ddeeccaecbc7493b8c7570 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 8 Feb 2021 15:42:45 +0100 Subject: [PATCH 5/5] fix a rounding error with coupons --- app/services/coupon_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/coupon_service.rb b/app/services/coupon_service.rb index 68287c727..05f8c9964 100644 --- a/app/services/coupon_service.rb +++ b/app/services/coupon_service.rb @@ -25,7 +25,7 @@ class CouponService unless coupon_object.nil? if coupon_object.status(user_id, total) == 'active' if coupon_object.type == 'percent_off' - price -= price * coupon_object.percent_off / 100.00 + price -= (price * coupon_object.percent_off / 100.00).truncate elsif coupon_object.type == 'amount_off' # do not apply cash coupon unless it has a lower amount that the total price price -= coupon_object.amount_off if coupon_object.amount_off <= price