diff --git a/CHANGELOG.md b/CHANGELOG.md index cab782b12..cfdfa6e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Ability to select "bank transfer" as the payment mean for a payment schedule - When a payment schedule was canceled by the payment gateway, alert the users - When a payment schedule is in error, alert the users -- Specilized VAT rate cannot be defined unless the VAT is enabled and saved +- When a payment schedule is in error or canceled, ability to re-enable it with another payment method - Fix card image ratio - Update events heading style - Update some icons diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index 6b2050ed0..b994c8eb7 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -3,7 +3,7 @@ # API Controller for resources of PaymentSchedule class API::PaymentSchedulesController < API::ApiController before_action :authenticate_user! - before_action :set_payment_schedule, only: %i[download cancel] + before_action :set_payment_schedule, only: %i[download cancel update] before_action :set_payment_schedule_item, only: %i[show_item cash_check confirm_transfer refresh_item pay_item] # retrieve all payment schedules for the current user, paginated @@ -85,6 +85,17 @@ class API::PaymentSchedulesController < API::ApiController render json: { canceled_at: canceled_at }, status: :ok end + ## Only the update of the payment method is allowed + def update + authorize PaymentSchedule + + if PaymentScheduleService.new.update_payment_mean(@payment_schedule, update_params) + render :show, status: :ok, location: @payment_schedule + else + render json: @payment_schedule.errors, status: :unprocessable_entity + end + end + private def set_payment_schedule @@ -94,4 +105,8 @@ class API::PaymentSchedulesController < API::ApiController def set_payment_schedule_item @payment_schedule_item = PaymentScheduleItem.find(params[:id]) end + + def update_params + params.require(:payment_schedule).permit(:payment_method) + end end diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index 410f09681..c5ce67cf3 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -47,4 +47,9 @@ export default class PaymentScheduleAPI { const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`); return res?.data; } + + static async update (paymentSchedule: PaymentSchedule): Promise { + const res:AxiosResponse = await apiClient.patch(`/api/payment_schedules/${paymentSchedule.id}`, paymentSchedule); + return res?.data; + } } diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx index c968aaa44..bd041f268 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-item-actions.tsx @@ -13,11 +13,13 @@ import { FabModal } from '../base/fab-modal'; import FormatLib from '../../lib/format'; import { StripeConfirmModal } from '../payment/stripe/stripe-confirm-modal'; import { UpdateCardModal } from '../payment/update-card-modal'; +import { UpdatePaymentMeanModal } from './update-payment-mean-modal'; // we want to display some buttons only once. This is the types of buttons it applies to. export enum TypeOnce { CardUpdate = 'card-update', SubscriptionCancel = 'subscription-cancel', + UpdatePaymentMean = 'update-payment-mean' } interface PaymentScheduleItemActionsProps { @@ -47,6 +49,8 @@ export const PaymentScheduleItemActions: React.FC(false); // is open, the modal dialog to update the card details const [showUpdateCard, setShowUpdateCard] = useState(false); + // is open, the modal dialog to update the payment mean + const [showUpdatePaymentMean, setShowUpdatePaymentMean] = useState(false); // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); @@ -130,6 +134,23 @@ export const PaymentScheduleItemActions: React.FC { + const displayOnceStatus = displayOnceMap.get(TypeOnce.UpdatePaymentMean).get(paymentSchedule.id); + if (isPrivileged() && (!displayOnceStatus || displayOnceStatus === paymentScheduleItem.id)) { + displayOnceMap.get(TypeOnce.UpdatePaymentMean).set(paymentSchedule.id, paymentScheduleItem.id); + return ( + }> + {t('app.shared.payment_schedule_item_actions.update_payment_mean')} + + ); + } + }; + /** * Return a button to update the credit card associated with the payment schedule */ @@ -166,14 +187,18 @@ export const PaymentScheduleItemActions: React.FC { + const errorActions = (): ReactElement[] => { // if the payment schedule is canceled/in error, the schedule is over, and we can't update the card displayOnceMap.get(TypeOnce.CardUpdate).set(paymentSchedule.id, paymentScheduleItem.id); + + const buttons = []; if (isPrivileged()) { - return cancelSubscriptionButton(); + buttons.push(cancelSubscriptionButton()); + buttons.push(updatePaymentMeanButton()); } else { - return {t('app.shared.payment_schedule_item_actions.please_ask_reception')}; + buttons.push({t('app.shared.payment_schedule_item_actions.please_ask_reception')}); } + return buttons; }; /** @@ -232,6 +257,13 @@ export const PaymentScheduleItemActions: React.FC { + setShowUpdatePaymentMean(!showUpdatePaymentMean); + }; + /** * After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal. */ @@ -245,10 +277,10 @@ export const PaymentScheduleItemActions: React.FC { - PaymentScheduleAPI.confirmTransfer(paymentSchedule.id).then((res) => { + PaymentScheduleAPI.confirmTransfer(paymentScheduleItem.id).then((res) => { if (res.state === PaymentScheduleItemState.Paid) { onSuccess(); toggleConfirmTransferModal(); @@ -297,6 +329,14 @@ export const PaymentScheduleItemActions: React.FC { + onSuccess(); + toggleUpdatePaymentMeanModal(); + }; + if (!show) return null; return ( @@ -359,6 +399,12 @@ export const PaymentScheduleItemActions: React.FC + {/* Update the payment mean */} + ); diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index 46d15ccad..b36a40335 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -32,7 +32,8 @@ const PaymentSchedulesTableComponent: React.FC = ({ // we want to display some buttons only once. This map keep track of the buttons that have been displayed. const [displayOnceMap] = useState>>(new Map([ [TypeOnce.SubscriptionCancel, new Map()], - [TypeOnce.CardUpdate, new Map()] + [TypeOnce.CardUpdate, new Map()], + [TypeOnce.UpdatePaymentMean, new Map()] ])); /** diff --git a/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx new file mode 100644 index 000000000..20974e132 --- /dev/null +++ b/app/frontend/src/javascript/components/payment-schedule/update-payment-mean-modal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import Select from 'react-select'; +import { useTranslation } from 'react-i18next'; +import { FabModal } from '../base/fab-modal'; +import { PaymentMethod, PaymentSchedule } from '../../models/payment-schedule'; +import PaymentScheduleAPI from '../../api/payment-schedule'; + +interface UpdatePaymentMeanModalProps { + isOpen: boolean, + toggleModal: () => void, + onError: (message: string) => void, + afterSuccess: () => void, + paymentSchedule: PaymentSchedule +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: PaymentMethod, label: string }; + +export const UpdatePaymentMeanModal: React.FC = ({ isOpen, toggleModal, onError, afterSuccess, paymentSchedule }) => { + const { t } = useTranslation('admin'); + + const [paymentMean, setPaymentMean] = React.useState(); + + /** + * Convert all payment means to the react-select format + */ + const buildOptions = (): Array => { + return Object.keys(PaymentMethod).filter(pm => PaymentMethod[pm] !== PaymentMethod.Card).map(pm => { + return { value: PaymentMethod[pm], label: t(`app.admin.update_payment_mean_modal.method_${pm}`) }; + }); + }; + + /** + * When the payment mean is changed in the select, update the state + */ + const handleMeanSelected = (option: selectOption): void => { + setPaymentMean(option.value); + }; + + /** + * When the user clicks on the update button, update the default payment mean for the given payment schedule + */ + const handlePaymentMeanUpdate = (): void => { + PaymentScheduleAPI.update({ + id: paymentSchedule.id, + payment_method: paymentMean + }).then(() => { + afterSuccess(); + }).catch(error => { + onError(error.message); + }); + }; + + return ( + + {t('app.admin.update_payment_mean_modal.update_info')} + + + ); +}; diff --git a/app/frontend/src/javascript/controllers/admin/invoices.js b/app/frontend/src/javascript/controllers/admin/invoices.js index fe7794a4b..c78f857ec 100644 --- a/app/frontend/src/javascript/controllers/admin/invoices.js +++ b/app/frontend/src/javascript/controllers/admin/invoices.js @@ -476,8 +476,6 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I controller: ['$scope', '$uibModalInstance', 'rate', 'active', 'rateHistory', 'activeHistory', 'multiVAT', function ($scope, $uibModalInstance, rate, active, rateHistory, activeHistory, multiVAT) { $scope.rate = rate; $scope.isSelected = active; - // this one is read only - $scope.isActive = active; $scope.history = []; // callback on "enable VAT" switch toggle diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index 9f59437c2..81cf6d899 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -25,28 +25,28 @@ export interface PaymentScheduleItem { } export interface PaymentSchedule { - max_length: number; + max_length?: number; id: number, - total: number, - reference: string, + total?: number, + reference?: string, payment_method: PaymentMethod, - items: Array, - created_at: Date, - chained_footprint: boolean, - main_object: { + items?: Array, + created_at?: Date, + chained_footprint?: boolean, + main_object?: { type: string, id: number }, - user: { + user?: { id: number, name: string }, - operator: { + operator?: { id: number, first_name: string, last_name: string, }, - gateway: 'PayZen' | 'Stripe', + gateway?: 'PayZen' | 'Stripe', } export interface PaymentScheduleIndexRequest { diff --git a/app/frontend/templates/admin/invoices/settings/editVAT.html b/app/frontend/templates/admin/invoices/settings/editVAT.html index ad4c3a20d..e6f253268 100644 --- a/app/frontend/templates/admin/invoices/settings/editVAT.html +++ b/app/frontend/templates/admin/invoices/settings/editVAT.html @@ -51,7 +51,7 @@ diff --git a/app/policies/payment_schedule_policy.rb b/app/policies/payment_schedule_policy.rb index 866892c40..355426a48 100644 --- a/app/policies/payment_schedule_policy.rb +++ b/app/policies/payment_schedule_policy.rb @@ -2,7 +2,7 @@ # Check the access policies for API::PaymentSchedulesController class PaymentSchedulePolicy < ApplicationPolicy - %w[list? cash_check? confirm_transfer? cancel?].each do |action| + %w[list? cash_check? confirm_transfer? cancel? update?].each do |action| define_method action do user.admin? || user.manager? end diff --git a/app/services/payment_schedule_service.rb b/app/services/payment_schedule_service.rb index 97d3748da..934e6e040 100644 --- a/app/services/payment_schedule_service.rb +++ b/app/services/payment_schedule_service.rb @@ -157,6 +157,10 @@ class PaymentScheduleService ps end + ## + # Cancel the given PaymentSchedule: cancel the remote subscription on the payment gateway, mark the PaymentSchedule as cancelled, + # the remaining PaymentScheduleItems as canceled too, and cancel the associated Subscription. + ## def self.cancel(payment_schedule) PaymentGatewayService.new.cancel_subscription(payment_schedule) @@ -173,8 +177,26 @@ class PaymentScheduleService subscription.canceled_at end + ## + # Update the payment mean associated with the given PaymentSchedule and reset the erroneous items + ## + def update_payment_mean(payment_schedule, payment_mean) + payment_schedule.update(payment_mean) && reset_erroneous_payment_schedule_items(payment_schedule) + end + private + ## + # After the payment method has been updated, we need to reset the erroneous payment schedule items + # so the admin can confirm them to generate the invoice + ## + def reset_erroneous_payment_schedule_items(payment_schedule) + results = payment_schedule.payment_schedule_items.where(state: %w[error gateway_canceled]).map do |item| + item.update_attributes(state: item.due_date < DateTime.current ? 'pending' : 'new') + end + results.reduce(true) { |acc, item| acc && item } + end + ## # The first PaymentScheduleItem contains references to the reservation price (if any) and to the adjustment price # for the subscription (if any) and the wallet transaction (if any) diff --git a/app/views/api/payment_schedules/_payment_schedule.json.jbuilder b/app/views/api/payment_schedules/_payment_schedule.json.jbuilder index 1b7647444..8875f10c0 100644 --- a/app/views/api/payment_schedules/_payment_schedule.json.jbuilder +++ b/app/views/api/payment_schedules/_payment_schedule.json.jbuilder @@ -19,7 +19,7 @@ json.main_object do end if payment_schedule.gateway_subscription # this attribute is used to known which gateway should we interact with, in the front-end - json.gateway json.classname payment_schedule.gateway_subscription.gateway + json.gateway payment_schedule.gateway_subscription.gateway end json.items payment_schedule.payment_schedule_items do |item| json.partial! 'api/payment_schedules/payment_schedule_item', item: item diff --git a/app/views/api/payment_schedules/show.json.jbuilder b/app/views/api/payment_schedules/show.json.jbuilder index b19ebe915..e5e66219f 100644 --- a/app/views/api/payment_schedules/show.json.jbuilder +++ b/app/views/api/payment_schedules/show.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.partial! 'api/payment_schedules/payment_schedule', payment_schedule: @payment_schedule_item.payment_schedule +json.partial! 'api/payment_schedules/payment_schedule', payment_schedule: @payment_schedule diff --git a/app/views/api/settings/show.json.jbuilder b/app/views/api/settings/show.json.jbuilder index e93ed0bb1..4f57670af 100644 --- a/app/views/api/settings/show.json.jbuilder +++ b/app/views/api/settings/show.json.jbuilder @@ -1,7 +1,7 @@ json.setting do json.partial! 'api/settings/setting', setting: @setting if @show_history - json.history @setting.history_values do |value| + json.history @setting.history_values.includes(:invoicing_profile) do |value| json.extract! value, :id, :value, :created_at unless value.invoicing_profile.nil? json.user do diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 94d63d713..6555de0d7 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -762,6 +762,13 @@ en: reference: "Reference" customer: "Customer" date: "Date" + update_payment_mean_modal: + title: "Update the payment mean" + update_info: "Please specify below the new payment mean for this payment schedule to continue." + select_payment_mean: "Select a new payment mean" + method_Transfer: "By bank transfer" + method_Check: "By check" + confirm_button: "Update" #management of users, labels, groups, and so on members: users_management: "Users management" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index f891dbb7d..d0ff724ed 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -505,6 +505,7 @@ en: confirm_check: "Confirm cashing" resolve_action: "Resolve the action" update_card: "Update the card" + update_payment_mean: "Update the payment mean" please_ask_reception: "For any questions, please contact the FabLab's reception." confirm_button: "Confirm" confirm_check_cashing: "Confirm the cashing of the check" diff --git a/config/locales/en.yml b/config/locales/en.yml index 0fabfee8b..eb1bfe4d6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -376,9 +376,9 @@ en: notify_member_payment_schedule_failed: schedule_failed: "Failed card debit for the %{DATE} deadline, for your schedule %{REFERENCE}" notify_admin_payment_schedule_gateway_canceled: - schedule_error: "The payment schedule %{REFERENCE} was canceled by the gateway. An action is required." + schedule_canceled: "The payment schedule %{REFERENCE} was canceled by the gateway. An action is required." notify_member_payment_schedule_gateway_canceled: - schedule_error: "Your payment schedule %{REFERENCE} was canceled by the gateway." + schedule_canceled: "Your payment schedule %{REFERENCE} was canceled by the gateway." notify_admin_payment_schedule_check_deadline: schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" notify_admin_payment_schedule_transfer_deadline: diff --git a/config/routes.rb b/config/routes.rb index bdbd3cff2..c9a0728ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -119,7 +119,7 @@ Rails.application.routes.draw do get 'first', action: 'first', on: :collection end - resources :payment_schedules, only: %i[index show] do + resources :payment_schedules, only: %i[index show update] do post 'list', action: 'list', on: :collection put 'cancel', on: :member get 'download', on: :member