1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-17 06:52:27 +01:00

Ability to cancel a payement schedule from the interface

This commit is contained in:
Sylvain 2022-01-03 17:13:35 +01:00
parent effe5c7ba9
commit f3f15a2b9d
13 changed files with 115 additions and 34 deletions

View File

@ -1,5 +1,6 @@
# Changelog Fab-manager
- Ability to cancel a payement schedule from the interface
- Updated caniuse db
- Optimized the load time of the payment schedules list

View File

@ -2,5 +2,8 @@
# Raised when an an error occurred with the PayZen payment gateway
class PayzenError < PaymentGatewayError
def details
JSON.parse(message.gsub('=>', ':').gsub('nil', 'null'))
end
end

View File

@ -1,4 +1,4 @@
import React, { ReactEventHandler, useState } from 'react';
import React, { ReactElement, ReactEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import _ from 'lodash';
@ -6,7 +6,6 @@ import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { UpdateCardModal } from '../payment/update-card-modal';
import { StripeElements } from '../payment/stripe/stripe-elements';
import { StripeConfirm } from '../payment/stripe/stripe-confirm';
import { User, UserRole } from '../../models/user';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
import PaymentScheduleAPI from '../../api/payment-schedule';
@ -47,6 +46,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
// we want to display the card update button, only once. This is an association table keeping when we already shown one
const cardUpdateButton = new Map<number, boolean>();
// we want to display the cancel subscription button, only once. This is an association table keeping when we already shown one
const subscriptionCancelButton = new Map<number, boolean>();
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
@ -111,6 +112,21 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
);
};
/**
* Return a button to cancel the given subscription, if the user is privileged enough
*/
const cancelSubscriptionButton = (schedule: PaymentSchedule): ReactElement => {
if (isPrivileged() && !subscriptionCancelButton.get(schedule.id)) {
subscriptionCancelButton.set(schedule.id, true);
return (
<FabButton onClick={handleCancelSubscription(schedule)}
icon={<i className="fas fa-times" />}>
{t('app.shared.schedules_table.cancel_subscription')}
</FabButton>
);
}
};
/**
* Return the human-readable string for the status of the provided deadline.
*/
@ -165,25 +181,24 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
case PaymentScheduleItemState.Error:
// if the payment is in error, the schedule is over, and we can't update the card
cardUpdateButton.set(schedule.id, true);
if (isPrivileged()) {
return (
<FabButton onClick={handleCancelSubscription(schedule)}
icon={<i className="fas fa-times" />}>
{t('app.shared.schedules_table.cancel_subscription')}
</FabButton>
);
} else {
if (!isPrivileged()) {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>;
}
return cancelSubscriptionButton(schedule);
case PaymentScheduleItemState.New:
if (!cardUpdateButton.get(schedule.id)) {
if (!cardUpdateButton.get(schedule.id) && schedule.payment_method === 'card') {
cardUpdateButton.set(schedule.id, true);
return (
<FabButton onClick={handleUpdateCard(schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
<span>
<FabButton onClick={handleUpdateCard(schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
{cancelSubscriptionButton(schedule)}
</span>
);
} else {
return cancelSubscriptionButton(schedule);
}
return <span />;
default:
@ -263,7 +278,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
};
/**
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card.
* If a payementScheduleItem is provided, the payment will be triggered after a successful update (see handleCardUpdateSuccess).
*/
const handleUpdateCard = (paymentSchedule: PaymentSchedule, item?: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
@ -391,6 +407,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
</tbody>
</table>
<div className="modals">
{/* Confirm the cashing of the current deadline by check */}
<FabModal title={t('app.shared.schedules_table.confirm_check_cashing')}
isOpen={showConfirmCashing}
toggleModal={toggleConfirmCashingModal}
@ -404,6 +421,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
})}
</span>}
</FabModal>
{/* Cancel the subscription */}
<FabModal title={t('app.shared.schedules_table.cancel_subscription')}
isOpen={showCancelSubscription}
toggleModal={toggleCancelSubscriptionModal}
@ -413,10 +431,12 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
{t('app.shared.schedules_table.confirm_cancel_subscription')}
</FabModal>
<StripeElements>
{/* 3D secure confirmation */}
{tempDeadline && <StripeConfirmModal isOpen={showResolveAction}
toggleModal={toggleResolveActionModal}
onSuccess={afterAction}
paymentScheduleItemId={tempDeadline.id} />}
{/* Update credit card */}
{tempSchedule && <UpdateCardModal isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
operator={operator}

View File

@ -24,11 +24,7 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
const [gateway, setGateway] = useState<string>('');
useEffect(() => {
if (schedule.gateway_subscription.classname.match(/^PayZen::/)) {
setGateway('payzen');
} else if (schedule.gateway_subscription.classname.match(/^Stripe::/)) {
setGateway('stripe');
}
setGateway(schedule.gateway);
}, [schedule]);
/**
@ -44,7 +40,7 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
/**
* Render the PayZen update-card modal
*/ // 1
*/
const renderPayZenModal = (): ReactElement => {
return <PayzenCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
@ -58,15 +54,15 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
*/
switch (gateway) {
case 'stripe':
case 'Stripe':
return renderStripeModal();
case 'payzen':
case 'PayZen':
return renderPayZenModal();
case '':
return <div/>;
default:
onError(t('app.shared.update_card_modal.unexpected_error'));
console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway_subscription?.classname}`);
console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway} for schedule ${schedule.id}`);
return <div />;
}
};

View File

@ -26,7 +26,7 @@ export interface PaymentSchedule {
id: number,
total: number,
reference: string,
payment_method: string,
payment_method: 'card' | '',
items: Array<PaymentScheduleItem>,
created_at: Date,
chained_footprint: boolean,
@ -43,9 +43,7 @@ export interface PaymentSchedule {
first_name: string,
last_name: string,
},
gateway_subscription: {
classname: string
}
gateway: 'PayZen' | 'Stripe',
}
export interface PaymentScheduleIndexRequest {

View File

@ -23,6 +23,11 @@ class PaymentGatewayService
@gateway.create_subscription(payment_schedule, *args)
end
def cancel_subscription(payment_schedule)
gateway = service_for_payment_schedule(payment_schedule)
gateway.cancel_subscription(payment_schedule)
end
def create_user(user_id)
@gateway.create_user(user_id)
end
@ -52,7 +57,7 @@ class PaymentGatewayService
private
def service_for_payment_schedule(payment_schedule)
service = case payment_schedule.gateway_subscription.klass
service = case payment_schedule.gateway_subscription&.klass
when /^PayZen::/
require 'pay_zen/service'
PayZen::Service

View File

@ -158,6 +158,8 @@ class PaymentScheduleService
end
def self.cancel(payment_schedule)
PaymentGatewayService.new.cancel_subscription(payment_schedule)
# cancel all item where state != paid
payment_schedule.ordered_items.each do |item|
next if item.state == 'paid'

View File

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

View File

@ -48,6 +48,35 @@ class PayZen::Service < Payment::Service
pgo_sub.save!
end
def cancel_subscription(payment_schedule)
pz_subscription = payment_schedule.gateway_subscription.retrieve
order_client = PayZen::Order.new
tr_client = PayZen::Transaction.new
# first, we cancel all running transactions
begin
order = order_client.get(pz_subscription['answer']['orderId'])
order['answer']['transactions'].select { |t| t['status'] == 'RUNNING' }.each do |t|
tr_res = tr_client.cancel_or_refund(t['uuid'], amount: t['amount'], currency: t['currency'], resolution_mode: 'CANCELLATION_ONLY')
raise "Cannot cancel transaction #{t['uuid']}" unless tr_res['answer']['detailedStatus'] == 'CANCELLED'
end
rescue PayzenError => e
raise e unless e.details['errorCode'] == 'PSP_010' # ignore if no order
end
# then, we cancel the subscription
begin
sub_client = PayZen::Subscription.new
res = sub_client.cancel(pz_subscription['answer']['subscriptionId'], pz_subscription['answer']['paymentMethodToken'])
rescue PayzenError => e
return true if e.details['errorCode'] == 'PSP_033' # recurring payment already canceled
raise e
end
res['answer']['responseCode'].zero?
end
def process_payment_schedule_item(payment_schedule_item)
pz_order = payment_schedule_item.payment_schedule.gateway_order.retrieve
transaction = pz_order['answer']['transactions'].last

View File

@ -14,4 +14,11 @@ class PayZen::Subscription < PayZen::Client
def get(subscription_id, payment_method_token)
post('/Subscription/Get/', subscriptionId: subscription_id, paymentMethodToken: payment_method_token)
end
##
# @see https://payzen.io/fr-FR/rest/V4.0/api/playground/Subscription/Cancel/
##
def cancel(subscription_id, payment_method_token)
post('/Subscription/Cancel/', subscriptionId: subscription_id, paymentMethodToken: payment_method_token)
end
end

View File

@ -14,4 +14,15 @@ class PayZen::Transaction < PayZen::Client
def get(uuid)
post('/Transaction/Get/', uuid: uuid)
end
##
# @see https://payzen.io/fr-FR/rest/V4.0/api/playground/Transaction/CancelOrRefund
##
def cancel_or_refund(uuid,
amount: 0,
currency: Setting.get('payzen_currency'),
resolution_mode: nil,
comment: nil)
post('/Transaction/CancelOrRefund/', uuid: uuid, amount: amount, currency: currency, resolutionMode: resolution_mode, comment: comment)
end
end

View File

@ -8,6 +8,8 @@ module Payment; end
class Payment::Service
def create_subscription(_payment_schedule, *args); end
def cancel_subscription(_payment_schedule); end
def create_user(_user_id); end
def create_coupon(_coupon_id); end

View File

@ -43,6 +43,15 @@ class Stripe::Service < Payment::Service
pgo.save!
end
def cancel_subscription(payment_schedule)
stripe_key = Setting.get('stripe_secret_key')
stp_subscription = payment_schedule.gateway_subscription.retrieve
res = Stripe::Subscription.delete(stp_subscription.id, {}, api_key: stripe_key)
res.status == 'canceled'
end
def create_user(user_id)
StripeWorker.perform_async(:create_stripe_customer, user_id)
end