1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +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
- 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

View File

@ -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

View File

@ -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<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 { 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<PaymentScheduleItemActionsProp
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
// is open, the modal dialog to update the card details
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
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
*/
@ -166,14 +187,18 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
/**
* 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
displayOnceMap.get(TypeOnce.CardUpdate).set(paymentSchedule.id, paymentScheduleItem.id);
const buttons = [];
if (isPrivileged()) {
return cancelSubscriptionButton();
buttons.push(cancelSubscriptionButton());
buttons.push(updatePaymentMeanButton());
} 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);
};
/**
* 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.
*/
@ -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 => {
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<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;
return (
@ -359,6 +399,12 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
onError={onError}
schedule={paymentSchedule}>
</UpdateCardModal>
{/* Update the payment mean */}
<UpdatePaymentMeanModal isOpen={showUpdatePaymentMean}
toggleModal={toggleUpdatePaymentMeanModal}
onError={onError}
afterSuccess={onPaymentMeanUpdateSuccess}
paymentSchedule={paymentSchedule} />
</div>
</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.
const [displayOnceMap] = useState<Map<TypeOnce, Map<number, number>>>(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) {
$scope.rate = rate;
$scope.isSelected = active;
// this one is read only
$scope.isActive = active;
$scope.history = [];
// callback on "enable VAT" switch toggle

View File

@ -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<PaymentScheduleItem>,
created_at: Date,
chained_footprint: boolean,
main_object: {
items?: Array<PaymentScheduleItem>,
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 {

View File

@ -51,7 +51,7 @@
</div>
</div>
<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-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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:

View File

@ -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