1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

update the card for payment schedules

This commit is contained in:
Sylvain 2021-06-04 18:26:20 +02:00
parent 89adcf1a9d
commit 68f64cfc5c
50 changed files with 796 additions and 243 deletions

View File

@ -1,6 +1,9 @@
# Changelog Fab-manager
## Next release
- Ability to use PayZen a the payment gateway
- For payment schedules, ability to update the related payment card before the deadline
- Refactored data architecture to a more generic shopping cart model
- Redesigned the data structure to allow buying multiple and various objects
- Updated React and its dependencies to 17.0.3 and matching
- Updated the dependencies of: webpack, lodash, eslint, webpack-dev-server, react2angular, auto-ngtemplate-loader, angular-bootstrap-switch, react-refresh-webpack-plugin and eslint-plugin-react

View File

@ -4,6 +4,7 @@
class API::PayzenController < API::PaymentsController
require 'pay_zen/charge'
require 'pay_zen/order'
require 'pay_zen/token'
require 'pay_zen/transaction'
require 'pay_zen/helper'
@ -27,7 +28,8 @@ class API::PayzenController < API::PaymentsController
@result = client.create_payment(amount: amount[:amount],
order_id: @id,
customer: PayZen::Helper.generate_customer(params[:customer_id], current_user.id, params[:cart_items]))
error_handling
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def create_token
@ -35,7 +37,19 @@ class API::PayzenController < API::PaymentsController
client = PayZen::Charge.new
@result = client.create_token(order_id: @id,
customer: PayZen::Helper.generate_customer(params[:customer_id], current_user.id, params[:cart_items]))
error_handling
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def update_token
schedule = PaymentSchedule.find(params[:payment_schedule_id])
token = schedule.gateway_payment_mean
@id = schedule.gateway_order.id
@result = PayZen::Token.new.update(token.id,
PayZen::Helper.generate_customer(schedule.user.id, current_user.id, schedule.to_cart),
order_id: @id)
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def check_hash
@ -81,10 +95,4 @@ class API::PayzenController < API::PaymentsController
def on_payment_success(order_id, cart)
super(order_id, 'PayZen::Order', cart)
end
def error_handling
return unless @result['status'] == 'ERROR'
render json: { error: @result['answer']['detailedErrorMessage'] || @result['answer']['errorMessage'] }, status: :unprocessable_entity
end
end

View File

@ -5,6 +5,8 @@ class API::StripeController < API::PaymentsController
require 'stripe/helper'
require 'stripe/service'
before_action :check_keys
##
# Client requests to confirm a card payment will ask this endpoint.
# It will check for the need of a strong customer authentication (SCA) to confirm the payment or confirm that the payment
@ -86,6 +88,11 @@ class API::StripeController < API::PaymentsController
Stripe::Customer.update(user.payment_gateway_object.gateway_object_id,
{ invoice_settings: { default_payment_method: params[:payment_method_id] } },
{ api_key: key })
if params[:payment_schedule_id]
schedule = PaymentSchedule.find(params[:payment_schedule_id])
subscription = schedule.gateway_subscription.retrieve
Stripe::Subscription.update(subscription.id, { default_payment_method: params[:payment_method_id] }, { api_key: key })
end
render json: { updated: true }, status: :ok
rescue Stripe::StripeError => e
render json: { updated: false, error: e }, status: :unprocessable_entity
@ -128,4 +135,9 @@ class API::StripeController < API::PaymentsController
{ status: 500, json: { error: 'Invalid PaymentIntent status' } }
end
end
def check_keys
key = Setting.get('stripe_secret_key')
raise Stripe::StripeError, 'Using live keys in development mode' if key&.match(/^sk_live_/) && Rails.env.development?
end
end

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart } from '../models/payment';
import { ShoppingCart, UpdateCardResponse } from '../models/payment';
import { User } from '../models/user';
import {
CheckHashResponse,
@ -42,4 +42,9 @@ export default class PayzenAPI {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/payzen/confirm_payment_schedule', { cart_items: cart, order_id: orderId, transaction_uuid: transactionUuid });
return res?.data;
}
static async updateToken(payment_schedule_id: number): Promise<CreateTokenResponse> {
const res: AxiosResponse<CreateTokenResponse> = await apiClient.post(`/api/payzen/update_token`, { payment_schedule_id });
return res?.data;
}
}

View File

@ -26,10 +26,11 @@ export default class StripeAPI {
return res?.data;
}
static async updateCard (user_id: number, stp_payment_method_id: string): Promise<UpdateCardResponse> {
static async updateCard (user_id: number, stp_payment_method_id: string, payment_schedule_id?: number): Promise<UpdateCardResponse> {
const res: AxiosResponse<UpdateCardResponse> = await apiClient.post(`/api/stripe/update_card`, {
user_id,
payment_method_id: stp_payment_method_id,
payment_schedule_id
});
return res?.data;
}

View File

@ -12,7 +12,9 @@ import PaymentScheduleAPI from '../../api/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesDashboardProps {
currentUser: User
currentUser: User,
onError: (message: string) => void,
onCardUpdateSuccess: (message: string) => void,
}
// how many payment schedules should we display for each page?
@ -22,7 +24,7 @@ const PAGE_SIZE = 20;
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
* for the currentUser
*/
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('logged');
// list of displayed payment schedules
@ -47,21 +49,28 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}).catch((error) => onError(error.message));
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const handleRefreshList = (): void => {
const api = new PaymentScheduleAPI();
api.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
if (typeof onError === 'function') { onError(err.message); }
onError(err.message);
});
}
/**
* after a successful card update, provide a success message to the end-user
*/
const handleCardUpdateSuccess = (): void => {
onCardUpdateSuccess(t('app.logged.dashboard.payment_schedules.card_updated_success'));
}
/**
* Check if the current collection of payment schedules is empty or not.
*/
@ -80,7 +89,12 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
<div className="payment-schedules-dashboard">
{!hasSchedules() && <div>{t('app.logged.dashboard.payment_schedules.no_payment_schedules')}</div>}
{hasSchedules() && <div className="schedules-list">
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={false} refreshList={handleRefreshList} operator={currentUser} />
<PaymentSchedulesTable paymentSchedules={paymentSchedules}
showCustomer={false}
refreshList={handleRefreshList}
operator={currentUser}
onError={onError}
onCardUpdateSuccess={handleCardUpdateSuccess} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.logged.dashboard.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
@ -88,12 +102,12 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
}
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesDashboard currentUser={currentUser} />
<PaymentSchedulesDashboard currentUser={currentUser} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser']));
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess']));

View File

@ -13,7 +13,9 @@ import PaymentScheduleAPI from '../../api/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesListProps {
currentUser: User
currentUser: User,
onError: (message: string) => void,
onCardUpdateSuccess: (message: string) => void,
}
// how many payment schedules should we display for each page?
@ -22,7 +24,7 @@ const PAGE_SIZE = 20;
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('admin');
// list of displayed payment schedules
@ -54,7 +56,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
const api = new PaymentScheduleAPI();
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
setPaymentSchedules(res);
});
}).catch((error) => onError(error.message));
};
/**
@ -67,18 +69,18 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}).catch((error) => onError(error.message));
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const handleRefreshList = (): void => {
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
if (typeof onError === 'function') { onError(err.message); }
onError(err.message);
});
}
@ -96,6 +98,13 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
}
/**
* after a successful card update, provide a success message to the operator
*/
const handleCardUpdateSuccess = (): void => {
onCardUpdateSuccess(t('app.admin.invoices.payment_schedules.card_updated_success'));
}
return (
<div className="payment-schedules-list">
<h3>
@ -107,7 +116,12 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
</div>
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
{hasSchedules() && <div className="schedules-list">
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} operator={currentUser} />
<PaymentSchedulesTable paymentSchedules={paymentSchedules}
showCustomer={true}
refreshList={handleRefreshList}
operator={currentUser}
onError={onError}
onCardUpdateSuccess={handleCardUpdateSuccess} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
@ -115,12 +129,12 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
}
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesList currentUser={currentUser} />
<PaymentSchedulesList currentUser={currentUser} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess']));

View File

@ -5,31 +5,31 @@ import moment from 'moment';
import _ from 'lodash';
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 { StripeCardUpdate } from '../payment/stripe/stripe-card-update';
import { User, UserRole } from '../../models/user';
import { IFablab } from '../../models/fablab';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
import PaymentScheduleAPI from '../../api/payment-schedule';
import stripeLogo from '../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../images/mastercard.png';
import visaLogo from '../../../../images/visa.png';
import { useImmer } from 'use-immer';
import { SettingName } from '../../models/setting';
declare var Fablab: IFablab;
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
showCustomer?: boolean,
refreshList: (onError: (msg: any) => void) => void,
refreshList: () => void,
operator: User,
onError: (message: string) => void,
onCardUpdateSuccess: () => void
}
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('shared');
// for each payment schedule: are the details (all deadlines) shown or hidden?
@ -46,13 +46,12 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
// when an action is triggered on a deadline, the parent schedule is saved here until the action is done or cancelled.
const [tempSchedule, setTempSchedule] = useState<PaymentSchedule>(null);
// prevent submitting the form to update the card details, until all required fields are filled correctly
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
// errors are saved here, if any, for display purposes.
const [errors, setErrors] = useState<string>(null);
// is open, the modal dialog to cancel the associated subscription?
const [showCancelSubscription, setShowCancelSubscription] = useState<boolean>(false);
// 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>();
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
*/
@ -175,12 +174,14 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
);
case PaymentScheduleItemState.RequirePaymentMethod:
return (
<FabButton onClick={handleUpdateCard(item, schedule)}
<FabButton onClick={handleUpdateCard(schedule, item)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
);
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)}
@ -191,6 +192,17 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
} else {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
}
case PaymentScheduleItemState.New:
if (!cardUpdateButton.get(schedule.id)) {
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>
)
}
return <span />
default:
return <span />
}
@ -223,7 +235,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
* Refresh all payment schedules in the table
*/
const refreshSchedulesTable = (): void => {
refreshList(setErrors);
refreshList();
}
/**
@ -272,7 +284,7 @@ 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
*/
const handleUpdateCard = (item: PaymentScheduleItem, paymentSchedule: PaymentSchedule): ReactEventHandler => {
const handleUpdateCard = (paymentSchedule: PaymentSchedule, item?: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
setTempSchedule(paymentSchedule);
@ -287,46 +299,31 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
setShowUpdateCard(!showUpdateCard);
}
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="stripe-modal-icons">
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
<img src={stripeLogo} alt="powered by stripe" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* When the submit button is pushed, disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was successfully updated, pay the invoice (using the new payment method) and close the modal
*/
const handleCardUpdateSuccess = (): void => {
const api = new PaymentScheduleAPI();
api.payItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
if (tempDeadline) {
const api = new PaymentScheduleAPI();
api.payItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
onCardUpdateSuccess();
toggleUpdateCardModal();
}).catch((err) => {
handleCardUpdateError(err);
});
} else {
// if no tempDeadline (i.e. PaymentScheduleItem), then the user is updating his card number in a pro-active way, we don't need to trigger the payment
onCardUpdateSuccess();
toggleUpdateCardModal();
}).catch((err) => {
handleCardUpdateError(err);
});
}
}
/**
* When the card was not updated, show the error
* When the card was not updated, raise the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
onError(error);
}
/**
@ -445,31 +442,13 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
preventConfirm={isConfirmActionDisabled}>
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
</FabModal>
<FabModal title={t('app.shared.schedules_table.update_card')}
isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
closeButton={false}
customFooter={logoFooter()}
className="update-card-modal">
{tempDeadline && tempSchedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
onSuccess={handleCardUpdateSuccess}
onError={handleCardUpdateError}
customerId={tempSchedule.user.id}
operator={operator}
className="card-form" >
{errors && <div className="stripe-errors">
{errors}
</div>}
</StripeCardUpdate>}
<div className="submit-card">
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form="stripe-card" className="submit-card-btn">{t('app.shared.schedules_table.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
{tempSchedule && <UpdateCardModal isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
operator={operator}
afterSuccess={handleCardUpdateSuccess}
onError={handleCardUpdateError}
schedule={tempSchedule}>
</UpdateCardModal>}
</StripeElements>
</div>
</div>
@ -478,10 +457,10 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../../base/fab-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import payzenLogo from '../../../../../images/payzen-secure.png';
import { PayzenForm } from './payzen-form';
interface PayzenCardUpdateModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: () => void,
schedule: PaymentSchedule,
operator: User
}
export const PayzenCardUpdateModal: React.FC<PayzenCardUpdateModalProps> = ({ isOpen, toggleModal, onSuccess, schedule, operator }) => {
const { t } = useTranslation('shared');
// prevent submitting the form to update the card details, until the user has filled correctly all required fields
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
// we save errors here, if any, for display purposes.
const [errors, setErrors] = useState<string>(null);
// the unique identifier of the html form
const formId = "payzen-card";
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="payzen-modal-icons">
<img src={payzenLogo} alt="powered by PayZen" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* When the user clicks the submit button, we disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was not updated, show the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
}
return (
<FabModal title={t('app.shared.payzen_card_update_modal.update_card')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
customFooter={logoFooter()}
className="payzen-update-card-modal">
{schedule && <PayzenForm onSubmit={handleCardUpdateSubmit}
onSuccess={onSuccess}
onError={handleCardUpdateError}
className="card-form"
paymentSchedule={true}
operator={operator}
customer={schedule.user as User}
updateCard={true}
paymentScheduleId={schedule.id}
formId={formId} >
{errors && <div className="payzen-errors">
{errors}
</div>}
</PayzenForm>}
<div className="submit-card">
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form={formId} className="submit-card-btn">{t('app.shared.payzen_card_update_modal.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
);
}

View File

@ -14,11 +14,17 @@ import {
import { PaymentSchedule } from '../../../models/payment-schedule';
import { Invoice } from '../../../models/invoice';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
updateCard?: boolean,
paymentScheduleId: number,
}
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => {
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, updateCard = false, cart, customer, formId, paymentScheduleId }) => {
const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
@ -48,7 +54,9 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
* Depending on the current transaction (schedule or not), a PayZen Token or Payment may be created.
*/
const createToken = async (): Promise<CreateTokenResponse> => {
if (paymentSchedule) {
if (updateCard) {
return await PayzenAPI.updateToken(paymentScheduleId);
} else if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cart, customer);
} else {
return await PayzenAPI.chargeCreatePayment(cart, customer);
@ -62,8 +70,9 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
const onPaid = (event: ProcessPaymentAnswer): boolean => {
PayzenAPI.checkHash(event.hashAlgorithm, event.hashKey, event.hash, event.rawClientAnswer).then(async (hash) => {
if (hash.validity) {
const transaction = event.clientAnswer.transactions[0];
if (updateCard) return onSuccess(null);
const transaction = event.clientAnswer.transactions[0];
if (event.clientAnswer.orderStatus === 'PAID') {
confirmPayment(event, transaction).then((confirmation) => {
PayZenKR.current.removeForms().then(() => {
@ -81,13 +90,12 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
/**
* Confirm the payment, depending on the current type of payment (single shot or recurring)
* @param event
*/
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule> => {
if (!paymentSchedule) {
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
} else {
if (paymentSchedule) {
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
} else {
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
}
}

View File

@ -0,0 +1,84 @@
import React, { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../../base/fab-modal';
import { StripeCardUpdate } from './stripe-card-update';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import stripeLogo from '../../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
interface StripeCardUpdateModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: () => void,
schedule: PaymentSchedule,
operator: User
}
export const StripeCardUpdateModal: React.FC<StripeCardUpdateModalProps> = ({ isOpen, toggleModal, onSuccess, schedule, operator }) => {
const { t } = useTranslation('shared');
// prevent submitting the form to update the card details, until the user has filled correctly all required fields
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
// we save errors here, if any, for display purposes.
const [errors, setErrors] = useState<string>(null);
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="stripe-modal-icons">
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
<img src={stripeLogo} alt="powered by stripe" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* When the user clicks the submit button, we disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was not updated, show the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
}
return (
<FabModal title={t('app.shared.stripe_card_update_modal.update_card')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
customFooter={logoFooter()}
className="stripe-update-card-modal">
{schedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
onSuccess={onSuccess}
onError={handleCardUpdateError}
schedule={schedule}
operator={operator}
className="card-form" >
{errors && <div className="stripe-errors">
{errors}
</div>}
</StripeCardUpdate>}
<div className="submit-card">
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form="stripe-card" className="submit-card-btn">{t('app.shared.stripe_card_update_modal.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
);
}

View File

@ -1,15 +1,14 @@
import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { SetupIntent } from "@stripe/stripe-js";
import { PaymentConfirmation } from '../../../models/payment';
import { User } from '../../../models/user';
import StripeAPI from '../../../api/stripe';
import { PaymentSchedule } from '../../../models/payment-schedule';
interface StripeCardUpdateProps {
onSubmit: () => void,
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onSuccess: () => void,
onError: (message: string) => void,
customerId: number,
schedule: PaymentSchedule
operator: User,
className?: string,
}
@ -19,7 +18,7 @@ interface StripeCardUpdateProps {
*
* The form validation button must be created elsewhere, using the attribute form="stripe-card".
*/
export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, onSuccess, onError, className, customerId, operator, children }) => {
export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, onSuccess, onError, className, schedule, operator, children }) => {
const stripe = useStripe();
const elements = useElements();
@ -47,7 +46,7 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
} else {
try {
// we start by associating the payment method with the user
const { client_secret } = await StripeAPI.setupIntent(customerId);
const { client_secret } = await StripeAPI.setupIntent(schedule.user.id);
const { error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id,
mandate_data: {
@ -64,8 +63,12 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
onError(error.message);
} else {
// then we update the default payment method
const res = await StripeAPI.updateCard(customerId, paymentMethod.id);
onSuccess(res);
const res = await StripeAPI.updateCard(schedule.user.id, paymentMethod.id, schedule.id);
if (res.updated) {
onSuccess();
} else {
onError(res.error);
}
}
} catch (err) {
// catch api errors

View File

@ -0,0 +1,83 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { Loader } from '../base/loader';
import { StripeCardUpdateModal } from './stripe/stripe-card-update-modal';
import { PayzenCardUpdateModal } from './payzen/payzen-card-update-modal';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { useTranslation } from 'react-i18next';
interface UpdateCardModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: () => void,
onError: (message: string) => void,
schedule: PaymentSchedule,
operator: User
}
/**
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => {
const { t } = useTranslation('shared');
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');
}
}, [schedule]);
/**
* Render the Stripe update-card modal
*/
const renderStripeModal = (): ReactElement => {
return <StripeCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
onSuccess={afterSuccess}
operator={operator}
schedule={schedule} />
}
/**
* Render the PayZen update-card modal
*/ // 1
const renderPayZenModal = (): ReactElement => {
return <PayzenCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
onSuccess={afterSuccess}
operator={operator}
schedule={schedule} />
}
/**
* Determine which gateway is in use with the current schedule and return the appropriate modal
*/
switch (gateway) {
case 'stripe':
return renderStripeModal();
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}`);
return <div />;
}
}
export const UpdateCardModal: React.FC<UpdateCardModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => {
return (
<Loader>
<UpdateCardModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} operator={operator} schedule={schedule} />
</Loader>
);
}

View File

@ -703,6 +703,20 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
}
};
/**
* Callback used in PaymentScheduleList, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered after the gateway failed to be configured
*/

View File

@ -62,6 +62,20 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
return networks;
};
/**
* Callback used in PaymentScheduleDashboard, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}

View File

@ -20,23 +20,15 @@ export interface PaymentScheduleItem {
state: PaymentScheduleItemState,
invoice_id: number,
payment_method: PaymentMethod,
client_secret?: string,
details: {
recurring: number,
adjustment?: number,
other_items?: number,
without_coupon?: number
}
client_secret?: string
}
export interface PaymentSchedule {
max_length: number;
id: number,
total: number,
stp_subscription_id: string,
reference: string,
payment_method: string,
wallet_amount: number,
items: Array<PaymentScheduleItem>,
created_at: Date,
chained_footprint: boolean,
@ -52,6 +44,9 @@ export interface PaymentSchedule {
id: number,
first_name: string,
last_name: string,
},
gateway_subscription: {
classname: string
}
}

View File

@ -19,7 +19,7 @@ export enum PaymentMethod {
Other = ''
}
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest };
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ card_update: { date: Date } };
export interface ShoppingCart {
customer_id: number,

View File

@ -41,5 +41,7 @@
@import "modules/payzen-settings";
@import "modules/payment-modal";
@import "modules/payzen-modal";
@import "modules/stripe-update-card-modal";
@import "modules/payzen-update-card-modal";
@import "app.responsive";

View File

@ -121,61 +121,3 @@
color: black;
}
}
.fab-modal.update-card-modal {
.fab-modal-content {
.card-form {
background-color: #f4f3f3;
border: 1px solid #ddd;
border-radius: 6px 6px 0 0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 15px;
.stripe-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
}
}
.submit-card {
.submit-card-btn {
width: 100%;
border: 1px solid #ddd;
border-radius: 0 0 6px 6px;
border-top: 0;
padding: 16px;
color: #fff;
background-color: #1d98ec;
margin-bottom: 15px;
&[disabled] {
background-color: lighten(#1d98ec, 20%);
}
}
.payment-pending {
@extend .submit-card-btn;
background-color: lighten(#1d98ec, 20%);
text-align: center;
padding: 4px;
}
}
}
.fab-modal-footer {
.stripe-modal-icons {
& {
text-align: center;
}
.fa.fa-lock {
top: 7px;
color: #9edd78;
}
img {
margin-right: 10px;
}
}
}
}

View File

@ -0,0 +1,73 @@
.payzen-update-card-modal {
.card-form {
background-color: #f4f3f3;
border: 1px solid #ddd;
border-radius: 6px 6px 0 0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 15px;
.payzen-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
}
.hidden {
display: none;
}
.loader {
text-align: center;
}
.loader-overlay {
position: absolute;
top: 65px;
left: 190px;
z-index: 1;
}
.container {
display: flex;
justify-content: center;
width: inherit;
.kr-payment-button {
display: none;
}
.kr-form-error {
display: none;
}
}
}
.fab-modal-content {
.submit-card {
.submit-card-btn {
width: 100%;
border: 1px solid #ddd;
border-radius: 0 0 6px 6px;
border-top: 0;
padding: 16px;
color: #fff;
background-color: #1d98ec;
margin-bottom: 15px;
&[disabled] {
background-color: lighten(#1d98ec, 20%);
}
}
.payment-pending {
@extend .submit-card-btn;
background-color: lighten(#1d98ec, 20%);
text-align: center;
padding: 4px;
}
}
}
.payzen-modal-icons {
text-align: center;
img {
margin-right: 10px;
}
}
}

View File

@ -0,0 +1,57 @@
.stripe-update-card-modal {
.fab-modal-content {
.card-form {
background-color: #f4f3f3;
border: 1px solid #ddd;
border-radius: 6px 6px 0 0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 15px;
.stripe-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
}
}
.submit-card {
.submit-card-btn {
width: 100%;
border: 1px solid #ddd;
border-radius: 0 0 6px 6px;
border-top: 0;
padding: 16px;
color: #fff;
background-color: #1d98ec;
margin-bottom: 15px;
&[disabled] {
background-color: lighten(#1d98ec, 20%);
}
}
.payment-pending {
@extend .submit-card-btn;
background-color: lighten(#1d98ec, 20%);
text-align: center;
padding: 4px;
}
}
}
.fab-modal-footer {
.stripe-modal-icons {
& {
text-align: center;
}
.fa.fa-lock {
top: 7px;
color: #9edd78;
}
img {
margin-right: 10px;
}
}
}
}

View File

@ -35,7 +35,7 @@
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" ng-show="$root.modules.invoicing" index="4" class="payment-schedules-list">
<payment-schedules-list current-user="currentUser" />
<payment-schedules-list current-user="currentUser" on-error="onError" on-card-update-success="onCardUpdateSuccess" />
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">
@ -59,7 +59,7 @@
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" index="4" class="payment-schedules-list">
<payment-schedules-list current-user="currentUser" />
<payment-schedules-list current-user="currentUser" on-error="onError" on-card-update-success="onCardUpdateSuccess" />
</uib-tab>
</uib-tabset>
</div>

View File

@ -7,5 +7,5 @@
</section>
<payment-schedules-dashboard current-user="currentUser" />
<payment-schedules-dashboard current-user="currentUser" on-error="onError" on-card-update-success="onCardUpdateSuccess" />
</div>

View File

@ -53,7 +53,7 @@ class NotificationType
notify_admin_refund_created
notify_admins_role_update
notify_user_role_update
notify_admin_members_stripe_sync
notify_admin_objects_stripe_sync
notify_user_when_payment_schedule_ready
notify_admin_payment_schedule_failed
notify_member_payment_schedule_failed

View File

@ -50,11 +50,15 @@ class PaymentSchedule < PaymentDocument
end
def gateway_subscription
payment_gateway_objects.map(&:gateway_object).find { |item| !item.payment_mean? }
payment_gateway_objects.map(&:gateway_object).find(&:subscription?)
end
def gateway_order
payment_gateway_objects.map(&:gateway_object).find(&:order?)
end
def main_object
payment_schedule_objects.where(main: true).first
payment_schedule_objects.find_by(main: true)
end
def user
@ -81,6 +85,11 @@ class PaymentSchedule < PaymentDocument
{ partial: 'api/payment_schedules/payment_schedule', locals: { payment_schedule: self } }
end
def to_cart
service = CartService.new(operator_profile.user)
service.from_payment_schedule(self)
end
private
def generate_and_send_document

View File

@ -36,6 +36,32 @@ class CartService
)
end
def from_payment_schedule(payment_schedule)
@customer = payment_schedule.user
plan = payment_schedule.payment_schedule_objects.find(&:subscription)&.subscription&.plan
coupon = CartItem::Coupon.new(@customer, @operator, payment_schedule.coupon&.code)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true)
items = []
payment_schedule.payment_schedule_objects.each do |object|
if object.subscription
items.push(CartItem::Subscription.new(object.subscription.plan, @customer))
elsif object.reservation
items.push(reservable_from_payment_schedule_object(object, plan))
end
end
ShoppingCart.new(
@customer,
@operator,
coupon,
schedule,
payment_schedule.payment_method,
items: items
)
end
private
def plan(cart_items)
@ -98,4 +124,41 @@ class CartService
raise NotImplementedError
end
end
def reservable_from_payment_schedule_object(object, plan)
reservable = object.reservation.reservable
case reservable
when Machine
CartItem::MachineReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
plan: plan,
new_subscription: true)
when Training
CartItem::TrainingReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
plan: plan,
new_subscription: true)
when Event
CartItem::EventReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
normal_tickets: object.reservation.nb_reserve_places,
other_tickets: object.reservation.tickets)
when Space
CartItem::SpaceReservation.new(@customer,
@operator,
reservable,
object.reservation.slots,
plan: plan,
new_subscription: true)
else
STDERR.puts "WARNING: the reservable #{reservable} is not implemented"
raise NotImplementedError
end
end
end

View File

@ -48,7 +48,7 @@ class PaymentGatewayService
private
def service_for_payment_schedule(payment_schedule)
service = case payment_schedule_item.payment_schedule.gateway_subscription.klass
service = case payment_schedule.gateway_subscription.klass
when /^PayZen::/
require 'pay_zen/service'
PayZen::Service

View File

@ -18,8 +18,8 @@ class SettingService
# notify about a change in privacy policy
NotifyPrivacyUpdateWorker.perform_async(setting.id) if setting.name == 'privacy_body'
# sync all users on stripe
SyncMembersOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) if setting.name == 'stripe_secret_key'
# sync all objects on stripe
SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) if setting.name == 'stripe_secret_key'
# generate statistics
PeriodStatisticsWorker.perform_async(setting.previous_update) if setting.name == 'statistics_module' && setting.value == 'true'

View File

@ -1,2 +1,2 @@
json.title notification.notification_type
json.description t('.all_members_sync')
json.description t('.all_objects_sync')

View File

@ -17,6 +17,12 @@ json.main_object do
json.type payment_schedule.main_object.object_type
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
end
json.items payment_schedule.payment_schedule_items do |item|
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
json.amount item.amount / 100.00

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
json.formToken @result['answer']['formToken']
json.orderId @id

View File

@ -20,8 +20,7 @@
<script type="text/javascript" src="//cdn.crowdin.com/jipt/jipt.js"></script>
<% end %>
<% require 'pay_zen/helper' %>
<% if PayZen::Helper.enabled? %>
<% if Setting.get('payzen_endpoint') %>
<% endpoint = Setting.get('payzen_endpoint') %>
<%=stylesheet_link_tag "#{endpoint}/static/js/krypton-client/V4.0/ext/classic-reset.css" %>
<%=javascript_include_tag "#{endpoint}/static/js/krypton-client/V4.0/ext/classic.js" %>

View File

@ -1 +0,0 @@
<p><%= t('.body.members_sync') %></p>

View File

@ -0,0 +1 @@
<p><%= t('.body.objects_sync') %></p>

View File

@ -21,7 +21,9 @@ class StripeWorker
},
{ api_key: Setting.get('stripe_secret_key') }
)
user.update_columns(stp_customer_id: customer.id)
pgo = PaymentGatewayObject.find_or_initialize_by(item: user)
pgo.gateway_object = customer
pgo.save!
end
def delete_stripe_coupon(coupon_code)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
# This worker perform various requests to the Stripe API (payment service)
class SyncMembersOnStripeWorker
class SyncObjectsOnStripeWorker
include Sidekiq::Worker
sidekiq_options lock: :until_executed, on_conflict: :reject, queue: :stripe
@ -17,12 +17,31 @@ class SyncMembersOnStripeWorker
StripeWorker.new.create_stripe_customer(member.id)
end
end
logger.debug 'We create all non-existing coupons on stripe. This may take a while...'
total = Coupon.all.count
Coupon.all.each_with_index do |coupon, index|
logger.debug "#{index} / #{total}"
Stripe::Coupon.retrieve(c.code, api_key: Setting.get('stripe_secret_key'))
rescue Stripe::InvalidRequestError
Stripe::Service.create_coupon(c.id)
end
w = StripeWorker.new
[Machine, Training, Space, Plan].each do |klass|
logger.debug "We create all non-existing #{klass} on stripe. This may take a while..."
total = klass.all.count
klass.all.each_with_index do |item, index|
logger.debug "#{index} / #{total}"
w.perform(:create_or_update_stp_product, klass.name, item.id)
end
end
logger.debug 'Sync is done'
return unless notify_user_id
logger.debug "Notify user #{notify_user_id}"
user = User.find(notify_user_id)
NotificationCenter.call type: :notify_admin_members_stripe_sync,
NotificationCenter.call type: :notify_admin_objects_stripe_sync,
receiver: user,
attached_object: user
end

View File

@ -677,6 +677,7 @@ en:
filter_schedules: "Filter schedules"
no_payment_schedules: "No payment schedules to display"
load_more: "Load more"
card_updated_success: "The user's card was successfully updated"
document_filters:
reference: "Reference"
customer: "Customer"

View File

@ -132,6 +132,7 @@ en:
payment_schedules:
no_payment_schedules: "No payment schedules to display"
load_more: "Load more"
card_updated_success: "Your card was successfully updated"
#public profil of a member
members_show:
members_list: "Members list"

View File

@ -507,10 +507,17 @@ en:
confirm_button: "Confirm"
resolve_action: "Resolve the action"
ok_button: "OK"
validate_button: "Validate the new card"
cancel_subscription: "Cancel the subscription"
confirm_cancel_subscription: "You're about to cancel this payment schedule and the related subscription. Are you sure?"
please_ask_reception: "For any questions, please contact the FabLab's reception."
payment_modal:
online_payment_disabled: "Online payment is not available. Please contact the FabLab's reception directly."
unexpected_error: "An error occurred. Please report this issue to the Fab-Manager's team."
update_card_modal:
unexpected_error: "An error occurred. Please report this issue to the Fab-Manager's team."
stripe_card_update_modal:
update_card: "Update the card"
validate_button: "Validate the new card"
payzen_card_update_modal:
update_card: "Update the card"
validate_button: "Validate the new card"

View File

@ -354,8 +354,8 @@ en:
your_role_is_ROLE: "Your role has been changed to %{ROLE}."
notify_admins_role_update:
user_NAME_changed_ROLE_html: "User <strong><em>%{NAME}</strong></em> is now %{ROLE}."
notify_admin_members_stripe_sync:
all_members_sync: "All members were successfully synchronized on Stripe."
notify_admin_objects_stripe_sync:
all_objects_sync: "All data were successfully synchronized on Stripe."
notify_user_when_payment_schedule_ready:
your_schedule_is_ready_html: "Your payment schedule #%{REFERENCE}, of %{AMOUNT}, is ready. <a href='api/payment_schedules/%{SCHEDULE_ID}/download' target='_blank'>Click here to download</a>."
notify_admin_payment_schedule_failed:

View File

@ -285,10 +285,10 @@ en:
subject: "Your role has changed"
body:
role_changed_html: "Your role at {GENDER, select, male{the} female{the} neutral{} other{the}} {NAME} has changed. You are now <strong>{ROLE}</strong>.<br/>With great power comes great responsibility, use your new privileges fairly and respectfully."
notify_admin_members_stripe_sync:
notify_admin_objects_stripe_sync:
subject: "Stripe synchronization"
body:
members_sync: "All members were successfully synchronized on Stripe."
objects_sync: "All members, coupons, machines, trainings, spaces and plans were successfully synchronized on Stripe."
notify_member_payment_schedule_ready:
subject: "Your payment schedule"
body:

View File

@ -186,6 +186,7 @@ Rails.application.routes.draw do
post 'payzen/confirm_payment_schedule' => 'payzen#confirm_payment_schedule'
post 'payzen/check_hash' => 'payzen#check_hash'
post 'payzen/create_token' => 'payzen#create_token'
post 'payzen/update_token' => 'payzen#update_token'
# local payments handling
post 'local_payment/confirm_payment' => 'local_payment#confirm_payment'

View File

@ -57,8 +57,12 @@ class PayZen::Helper
## Generate a hash map compatible with PayZen 'V4/Customer/ShoppingCart'
def generate_shopping_cart(cart_items, customer, operator)
cs = CartService.new(operator)
cart = cs.from_hash(cart_items)
cart = if cart_items.is_a? ShoppingCart
cart_items
else
cs = CartService.new(operator)
cs.from_hash(cart_items)
end
{
cartItemInfo: cart.items.map do |item|
{

View File

@ -26,4 +26,12 @@ class PayZen::Item < Payment::Item
def payment_mean?
klass == 'PayZen::Token'
end
def subscription?
klass == 'PayZen::Subscription'
end
def order?
klass == 'PayZen::Order'
end
end

View File

@ -49,39 +49,35 @@ class PayZen::Service < Payment::Service
end
def process_payment_schedule_item(payment_schedule_item)
pz_order = payment_schedule_item.payment_schedule.payment_gateway_objects.find { |pgo| pgo.gateway_object_type == 'PayZen::Order' }.gateway_object.retrieve
pz_order = payment_schedule_item.payment_schedule.gateway_order.retrieve
transaction = pz_order['answer']['transactions'].last
transaction_date = DateTime.parse(transaction['creationDate']).to_date
# check that the transaction matches the current PaymentScheduleItem
if transaction['amount'] == payment_schedule_item.amount &&
transaction_date >= payment_schedule_item.due_date.to_date &&
transaction_date <= payment_schedule_item.due_date.to_date + 7.days
if transaction['status'] == 'PAID'
PaymentScheduleService.new.generate_invoice(payment_schedule_item,
payment_method: 'card',
payment_id: transaction['uuid'],
payment_type: 'PayZen::Transaction')
payment_schedule_item.update_attributes(state: 'paid', payment_method: 'card')
pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item)
pgo.gateway_object = PayZen::Item.new('PayZen::Transaction', transaction['uuid'])
pgo.save!
elsif transaction['status'] == 'RUNNING'
if payment_schedule_item.state == 'new'
# notify only for new deadlines, to prevent spamming
NotificationCenter.call type: 'notify_admin_payment_schedule_failed',
receiver: User.admins_and_managers,
attached_object: payment_schedule_item
NotificationCenter.call type: 'notify_member_payment_schedule_failed',
receiver: payment_schedule_item.payment_schedule.user,
attached_object: payment_schedule_item
end
payment_schedule_item.update_attributes(state: transaction['detailedStatus'])
pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item)
pgo.gateway_object = PayZen::Item.new('PayZen::Transaction', transaction['uuid'])
pgo.save!
else
payment_schedule_item.update_attributes(state: 'error')
return unless transaction_matches?(transaction, payment_schedule_item)
if transaction['status'] == 'PAID'
PaymentScheduleService.new.generate_invoice(payment_schedule_item,
payment_method: 'card',
payment_id: transaction['uuid'],
payment_type: 'PayZen::Transaction')
payment_schedule_item.update_attributes(state: 'paid', payment_method: 'card')
pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item)
pgo.gateway_object = PayZen::Item.new('PayZen::Transaction', transaction['uuid'])
pgo.save!
elsif transaction['status'] == 'RUNNING'
if payment_schedule_item.state == 'new'
# notify only for new deadlines, to prevent spamming
NotificationCenter.call type: 'notify_admin_payment_schedule_failed',
receiver: User.admins_and_managers,
attached_object: payment_schedule_item
NotificationCenter.call type: 'notify_member_payment_schedule_failed',
receiver: payment_schedule_item.payment_schedule.user,
attached_object: payment_schedule_item
end
payment_schedule_item.update_attributes(state: transaction['detailedStatus'])
pgo = PaymentGatewayObject.find_or_initialize_by(item: payment_schedule_item)
pgo.gateway_object = PayZen::Item.new('PayZen::Transaction', transaction['uuid'])
pgo.save!
else
payment_schedule_item.update_attributes(state: 'error')
end
end
@ -91,4 +87,13 @@ class PayZen::Service < Payment::Service
count = payment_schedule.payment_schedule_items.count
"RRULE:FREQ=MONTHLY;COUNT=#{count}"
end
# check if the given transaction matches the given PaymentScheduleItem
def transaction_matches?(transaction, payment_schedule_item)
transaction_date = DateTime.parse(transaction['creationDate']).to_date
transaction['amount'] == payment_schedule_item.amount &&
transaction_date >= payment_schedule_item.due_date.to_date &&
transaction_date <= payment_schedule_item.due_date.to_date + 7.days
end
end

View File

@ -14,4 +14,15 @@ class PayZen::Token < PayZen::Client
def get(payment_method_token)
post('/Token/Get/', paymentMethodToken: payment_method_token)
end
##
# @see https://payzen.io/en-EN/rest/V4.0/api/playground/Token/Update/
##
def update(payment_method_token, customer, order_id: nil, currency: Setting.get('payzen_currency'))
post('/Token/Update/',
paymentMethodToken: payment_method_token,
currency: currency,
orderId: order_id,
customer: customer)
end
end

View File

@ -21,5 +21,13 @@ class Payment::Item
false
end
def subscription?
false
end
def order?
false
end
def retrieve(_id = nil, *_args); end
end

View File

@ -17,4 +17,8 @@ class Stripe::Item < Payment::Item
def payment_mean?
klass == 'Stripe::SetupIntent'
end
def subscription?
klass == 'Stripe::Subscription'
end
end

View File

@ -37,10 +37,10 @@ namespace :fablab do
end
end
desc 'sync users to the stripe database'
task sync_members: :environment do
puts 'We create all non-existing customers on stripe. This may take a while, please wait...'
SyncMembersOnStripeWorker.new.perform
desc 'sync all objects to the stripe API'
task sync_objects: :environment do
puts 'We create all non-existing objects on stripe. This may take a while, please wait...'
SyncObjectsOnStripeWorker.new.perform
puts 'Done'
end
desc 'sync coupons to the stripe database'
@ -49,7 +49,7 @@ namespace :fablab do
Coupon.all.each do |c|
Stripe::Coupon.retrieve(c.code, api_key: Setting.get('stripe_secret_key'))
rescue Stripe::InvalidRequestError
StripeService.create_stripe_coupon(c.id)
Stripe::Service.create_coupon(c.id)
end
puts 'Done'
end

View File

@ -213,7 +213,7 @@ prepare_nginx()
if [ "$confirm" != "n" ]; then
echo "Adding a network configuration to the docker-compose.yml file..."
yq -i eval '.networks.web.external = "true"' docker-compose.yml
yq -i eval '.networks.db = null' docker-compose.yml
yq -i eval '.networks.db = "" | .networks.db tag="!!null"' docker-compose.yml
yq -i eval '.services.fabmanager.networks += ["web"]' docker-compose.yml
yq -i eval '.services.fabmanager.networks += ["db"]' docker-compose.yml
yq -i eval '.services.postgres.networks += ["db"]' docker-compose.yml