1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-21 15:54:22 +01:00

(feat) When a payment schedule is in error or canceled, ability to re-enable it with another payment method

This commit is contained in:
Sylvain 2022-01-17 12:38:53 +01:00
parent d8f27f0b1a
commit 634da414f8
18 changed files with 196 additions and 28 deletions

View File

@ -6,7 +6,7 @@
- Ability to select "bank transfer" as the payment mean for a payment schedule - 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 was canceled by the payment gateway, alert the users
- When a payment schedule is in error, 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 - Fix card image ratio
- Update events heading style - Update events heading style
- Update some icons - Update some icons

View File

@ -3,7 +3,7 @@
# API Controller for resources of PaymentSchedule # API Controller for resources of PaymentSchedule
class API::PaymentSchedulesController < API::ApiController class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user! 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] 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 # 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 render json: { canceled_at: canceled_at }, status: :ok
end 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 private
def set_payment_schedule def set_payment_schedule
@ -94,4 +105,8 @@ class API::PaymentSchedulesController < API::ApiController
def set_payment_schedule_item def set_payment_schedule_item
@payment_schedule_item = PaymentScheduleItem.find(params[:id]) @payment_schedule_item = PaymentScheduleItem.find(params[:id])
end end
def update_params
params.require(:payment_schedule).permit(:payment_method)
end
end end

View File

@ -47,4 +47,9 @@ export default class PaymentScheduleAPI {
const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`); const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`);
return res?.data; return res?.data;
} }
static async update (paymentSchedule: PaymentSchedule): Promise<PaymentSchedule> {
const res:AxiosResponse<PaymentSchedule> = await apiClient.patch(`/api/payment_schedules/${paymentSchedule.id}`, paymentSchedule);
return res?.data;
}
} }

View File

@ -13,11 +13,13 @@ import { FabModal } from '../base/fab-modal';
import FormatLib from '../../lib/format'; import FormatLib from '../../lib/format';
import { StripeConfirmModal } from '../payment/stripe/stripe-confirm-modal'; import { StripeConfirmModal } from '../payment/stripe/stripe-confirm-modal';
import { UpdateCardModal } from '../payment/update-card-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. // we want to display some buttons only once. This is the types of buttons it applies to.
export enum TypeOnce { export enum TypeOnce {
CardUpdate = 'card-update', CardUpdate = 'card-update',
SubscriptionCancel = 'subscription-cancel', SubscriptionCancel = 'subscription-cancel',
UpdatePaymentMean = 'update-payment-mean'
} }
interface PaymentScheduleItemActionsProps { interface PaymentScheduleItemActionsProps {
@ -47,6 +49,8 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
const [showResolveAction, setShowResolveAction] = useState<boolean>(false); const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
// is open, the modal dialog to update the card details // is open, the modal dialog to update the card details
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false); const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
// is open, the modal dialog to update the payment mean
const [showUpdatePaymentMean, setShowUpdatePaymentMean] = useState<boolean>(false);
// the user cannot confirm the action modal (3D secure), unless he has resolved the pending action // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true); const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
@ -130,6 +134,23 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
); );
}; };
/**
* Return a button to update the default payment mean for the current payment schedule
*/
const updatePaymentMeanButton = (): ReactElement => {
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 (
<FabButton key={`update-payment-mean-${paymentScheduleItem.id}`}
onClick={toggleUpdatePaymentMeanModal}
icon={<i className="fas fa-money-bill-alt" />}>
{t('app.shared.payment_schedule_item_actions.update_payment_mean')}
</FabButton>
);
}
};
/** /**
* Return a button to update the credit card associated with the payment schedule * Return a button to update the credit card associated with the payment schedule
*/ */
@ -166,14 +187,18 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
/** /**
* Return the actions button(s) for current paymentScheduleItem with state Error or GatewayCanceled * Return the actions button(s) for current paymentScheduleItem with state Error or GatewayCanceled
*/ */
const errorActions = (): ReactElement => { const errorActions = (): ReactElement[] => {
// if the payment schedule is canceled/in error, the schedule is over, and we can't update the card // 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); displayOnceMap.get(TypeOnce.CardUpdate).set(paymentSchedule.id, paymentScheduleItem.id);
const buttons = [];
if (isPrivileged()) { if (isPrivileged()) {
return cancelSubscriptionButton(); buttons.push(cancelSubscriptionButton());
buttons.push(updatePaymentMeanButton());
} else { } else {
return <span>{t('app.shared.payment_schedule_item_actions.please_ask_reception')}</span>; buttons.push(<span>{t('app.shared.payment_schedule_item_actions.please_ask_reception')}</span>);
} }
return buttons;
}; };
/** /**
@ -232,6 +257,13 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
setShowUpdateCard(!showUpdateCard); setShowUpdateCard(!showUpdateCard);
}; };
/**
* Show/hide the modal dialog to update the payment mean
*/
const toggleUpdatePaymentMeanModal = (): void => {
setShowUpdatePaymentMean(!showUpdatePaymentMean);
};
/** /**
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal. * 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<PaymentScheduleItemActionsProp
}; };
/** /**
* After the user has confirmed that he validates the tranfer, update the API, refresh the list and close the modal. * After the user has confirmed that he validates the transfer, update the API, refresh the list and close the modal.
*/ */
const onTransferConfirmed = (): void => { const onTransferConfirmed = (): void => {
PaymentScheduleAPI.confirmTransfer(paymentSchedule.id).then((res) => { PaymentScheduleAPI.confirmTransfer(paymentScheduleItem.id).then((res) => {
if (res.state === PaymentScheduleItemState.Paid) { if (res.state === PaymentScheduleItemState.Paid) {
onSuccess(); onSuccess();
toggleConfirmTransferModal(); toggleConfirmTransferModal();
@ -297,6 +329,14 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
}); });
}; };
/**
* When the update of the payment mean was successful, refresh the list and close the modal
*/
const onPaymentMeanUpdateSuccess = (): void => {
onSuccess();
toggleUpdatePaymentMeanModal();
};
if (!show) return null; if (!show) return null;
return ( return (
@ -359,6 +399,12 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
onError={onError} onError={onError}
schedule={paymentSchedule}> schedule={paymentSchedule}>
</UpdateCardModal> </UpdateCardModal>
{/* Update the payment mean */}
<UpdatePaymentMeanModal isOpen={showUpdatePaymentMean}
toggleModal={toggleUpdatePaymentMeanModal}
onError={onError}
afterSuccess={onPaymentMeanUpdateSuccess}
paymentSchedule={paymentSchedule} />
</div> </div>
</span> </span>
); );

View File

@ -32,7 +32,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
// we want to display some buttons only once. This map keep track of the buttons that have been displayed. // we want to display some buttons only once. This map keep track of the buttons that have been displayed.
const [displayOnceMap] = useState<Map<TypeOnce, Map<number, number>>>(new Map([ const [displayOnceMap] = useState<Map<TypeOnce, Map<number, number>>>(new Map([
[TypeOnce.SubscriptionCancel, new Map()], [TypeOnce.SubscriptionCancel, new Map()],
[TypeOnce.CardUpdate, new Map()] [TypeOnce.CardUpdate, new Map()],
[TypeOnce.UpdatePaymentMean, new Map()]
])); ]));
/** /**

View File

@ -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<UpdatePaymentMeanModalProps> = ({ isOpen, toggleModal, onError, afterSuccess, paymentSchedule }) => {
const { t } = useTranslation('admin');
const [paymentMean, setPaymentMean] = React.useState<PaymentMethod>();
/**
* Convert all payment means to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
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 (
<FabModal isOpen={isOpen}
className="update-payment-mean-modal"
title={t('app.admin.update_payment_mean_modal.title')}
confirmButton={t('app.admin.update_payment_mean_modal.confirm_button')}
onConfirm={handlePaymentMeanUpdate}
toggleModal={toggleModal}
closeButton={true}>
<span>{t('app.admin.update_payment_mean_modal.update_info')}</span>
<Select className="payment-mean-select"
placeholder={t('app.admin.update_payment_mean_modal.select_payment_mean')}
id="payment-mean"
onChange={handleMeanSelected}
options={buildOptions()}></Select>
</FabModal>
);
};

View File

@ -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) { controller: ['$scope', '$uibModalInstance', 'rate', 'active', 'rateHistory', 'activeHistory', 'multiVAT', function ($scope, $uibModalInstance, rate, active, rateHistory, activeHistory, multiVAT) {
$scope.rate = rate; $scope.rate = rate;
$scope.isSelected = active; $scope.isSelected = active;
// this one is read only
$scope.isActive = active;
$scope.history = []; $scope.history = [];
// callback on "enable VAT" switch toggle // callback on "enable VAT" switch toggle

View File

@ -25,28 +25,28 @@ export interface PaymentScheduleItem {
} }
export interface PaymentSchedule { export interface PaymentSchedule {
max_length: number; max_length?: number;
id: number, id: number,
total: number, total?: number,
reference: string, reference?: string,
payment_method: PaymentMethod, payment_method: PaymentMethod,
items: Array<PaymentScheduleItem>, items?: Array<PaymentScheduleItem>,
created_at: Date, created_at?: Date,
chained_footprint: boolean, chained_footprint?: boolean,
main_object: { main_object?: {
type: string, type: string,
id: number id: number
}, },
user: { user?: {
id: number, id: number,
name: string name: string
}, },
operator: { operator?: {
id: number, id: number,
first_name: string, first_name: string,
last_name: string, last_name: string,
}, },
gateway: 'PayZen' | 'Stripe', gateway?: 'PayZen' | 'Stripe',
} }
export interface PaymentScheduleIndexRequest { export interface PaymentScheduleIndexRequest {

View File

@ -51,7 +51,7 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-warning pull-left" ng-click="editMultiVAT()" ng-show="isActive" translate>{{ 'app.admin.invoices.edit_multi_VAT_button' }}</button> <button class="btn btn-warning pull-left" ng-click="editMultiVAT()" ng-show="isSelected" translate>{{ 'app.admin.invoices.edit_multi_VAT_button' }}</button>
<button class="btn btn-warning" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button> <button class="btn btn-warning" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button> <button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div> </div>

View File

@ -2,7 +2,7 @@
# Check the access policies for API::PaymentSchedulesController # Check the access policies for API::PaymentSchedulesController
class PaymentSchedulePolicy < ApplicationPolicy 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 define_method action do
user.admin? || user.manager? user.admin? || user.manager?
end end

View File

@ -157,6 +157,10 @@ class PaymentScheduleService
ps ps
end 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) def self.cancel(payment_schedule)
PaymentGatewayService.new.cancel_subscription(payment_schedule) PaymentGatewayService.new.cancel_subscription(payment_schedule)
@ -173,8 +177,26 @@ class PaymentScheduleService
subscription.canceled_at subscription.canceled_at
end 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 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 # 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) # for the subscription (if any) and the wallet transaction (if any)

View File

@ -19,7 +19,7 @@ json.main_object do
end end
if payment_schedule.gateway_subscription if payment_schedule.gateway_subscription
# this attribute is used to known which gateway should we interact with, in the front-end # 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 end
json.items payment_schedule.payment_schedule_items do |item| json.items payment_schedule.payment_schedule_items do |item|
json.partial! 'api/payment_schedules/payment_schedule_item', item: item json.partial! 'api/payment_schedules/payment_schedule_item', item: item

View File

@ -1,3 +1,3 @@
# frozen_string_literal: true # 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

View File

@ -1,7 +1,7 @@
json.setting do json.setting do
json.partial! 'api/settings/setting', setting: @setting json.partial! 'api/settings/setting', setting: @setting
if @show_history 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 json.extract! value, :id, :value, :created_at
unless value.invoicing_profile.nil? unless value.invoicing_profile.nil?
json.user do json.user do

View File

@ -762,6 +762,13 @@ en:
reference: "Reference" reference: "Reference"
customer: "Customer" customer: "Customer"
date: "Date" 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 #management of users, labels, groups, and so on
members: members:
users_management: "Users management" users_management: "Users management"

View File

@ -505,6 +505,7 @@ en:
confirm_check: "Confirm cashing" confirm_check: "Confirm cashing"
resolve_action: "Resolve the action" resolve_action: "Resolve the action"
update_card: "Update the card" update_card: "Update the card"
update_payment_mean: "Update the payment mean"
please_ask_reception: "For any questions, please contact the FabLab's reception." please_ask_reception: "For any questions, please contact the FabLab's reception."
confirm_button: "Confirm" confirm_button: "Confirm"
confirm_check_cashing: "Confirm the cashing of the check" confirm_check_cashing: "Confirm the cashing of the check"

View File

@ -376,9 +376,9 @@ en:
notify_member_payment_schedule_failed: notify_member_payment_schedule_failed:
schedule_failed: "Failed card debit for the %{DATE} deadline, for your schedule %{REFERENCE}" schedule_failed: "Failed card debit for the %{DATE} deadline, for your schedule %{REFERENCE}"
notify_admin_payment_schedule_gateway_canceled: 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: 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: notify_admin_payment_schedule_check_deadline:
schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}" schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline: notify_admin_payment_schedule_transfer_deadline:

View File

@ -119,7 +119,7 @@ Rails.application.routes.draw do
get 'first', action: 'first', on: :collection get 'first', action: 'first', on: :collection
end end
resources :payment_schedules, only: %i[index show] do resources :payment_schedules, only: %i[index show update] do
post 'list', action: 'list', on: :collection post 'list', action: 'list', on: :collection
put 'cancel', on: :member put 'cancel', on: :member
get 'download', on: :member get 'download', on: :member