1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

payment of cart by stripe/payzen/local

This commit is contained in:
Du Peng 2022-08-25 08:52:17 +02:00
parent 5ec541d854
commit 193c21a583
33 changed files with 526 additions and 60 deletions

View File

@ -10,7 +10,10 @@ class API::CartController < API::ApiController
def create
authorize :cart, :create?
@order = Order.find_by(token: order_token)
@order = Order.find_by(statistic_profile_id: current_user.statistic_profile.id, state: 'cart') if @order.nil? && current_user&.member?
if @order.nil? && current_user&.member?
@order = Order.where(statistic_profile_id: current_user.statistic_profile.id,
state: 'cart').last
end
if @order
@order.update(statistic_profile_id: current_user.statistic_profile.id) if @order.statistic_profile_id.nil? && current_user&.member?
@order.update(operator_id: current_user.id) if @order.operator_id.nil? && current_user&.privileged?
@ -37,13 +40,15 @@ class API::CartController < API::ApiController
render 'api/orders/show'
end
def set_customer
authorize @current_order, policy_class: CartPolicy
@order = Cart::SetCustomerService.new.call(@current_order, cart_params[:user_id])
render 'api/orders/show'
end
private
def orderable
Product.find(cart_params[:orderable_id])
end
def cart_params
params.permit(:order_token, :orderable_id, :quantity)
end
end

View File

@ -3,4 +3,21 @@
# API Controller for cart checkout
class API::CheckoutController < API::ApiController
include ::API::OrderConcern
before_action :authenticate_user!
before_action :current_order
before_action :ensure_order
def payment
res = Checkout::PaymentService.new.payment(@current_order, current_user, params[:payment_id])
render json: res
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
def confirm_payment
res = Checkout::PaymentService.new.confirm_payment(@current_order, current_user, params[:payment_id])
render json: res
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
end

View File

@ -15,4 +15,8 @@ module API::OrderConcern
def ensure_order
raise ActiveRecord::RecordNotFound if @current_order.nil?
end
def cart_params
params.permit(:order_token, :orderable_id, :quantity, :user_id)
end
end

View File

@ -22,4 +22,9 @@ export default class CartAPI {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity });
return res?.data;
}
static async setCustomer (order: Order, userId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_customer', { order_token: order.token, user_id: userId });
return res?.data;
}
}

View File

@ -0,0 +1,21 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { OrderPayment } from '../models/order';
export default class CheckoutAPI {
static async payment (token: string, paymentId?: string): Promise<OrderPayment> {
const res: AxiosResponse<OrderPayment> = await apiClient.post('/api/checkout/payment', {
order_token: token,
payment_id: paymentId
});
return res?.data;
}
static async confirmPayment (token: string, paymentId: string): Promise<OrderPayment> {
const res: AxiosResponse<OrderPayment> = await apiClient.post('/api/checkout/confirm_payment', {
order_token: token,
payment_id: paymentId
});
return res?.data;
}
}

View File

@ -9,6 +9,16 @@ export default class MemberAPI {
return res?.data;
}
static async search (name: string): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.get(`/api/members/search/${name}`);
return res?.data;
}
static async get (id: number): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get(`/api/members/${id}`);
return res?.data;
}
static async create (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
@ -8,12 +8,16 @@ import useCart from '../../hooks/use-cart';
import FormatLib from '../../lib/format';
import CartAPI from '../../api/cart';
import { User } from '../../models/user';
import { PaymentModal } from '../payment/stripe/payment-modal';
import { PaymentMethod } from '../../models/payment';
import { Order } from '../../models/order';
import { MemberSelect } from '../user/member-select';
declare const Application: IApplication;
interface StoreCartProps {
onError: (message: string) => void,
currentUser: User,
currentUser?: User,
}
/**
@ -23,6 +27,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
const { t } = useTranslation('public');
const { cart, setCart, reloadCart } = useCart();
const [paymentModal, setPaymentModal] = useState<boolean>(false);
useEffect(() => {
if (currentUser) {
@ -58,7 +63,38 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
* Checkout cart
*/
const checkout = () => {
console.log('checkout .....');
setPaymentModal(true);
};
/**
* Open/closes the payment modal
*/
const togglePaymentModal = (): void => {
setPaymentModal(!paymentModal);
};
/**
* Open/closes the payment modal
*/
const handlePaymentSuccess = (data: Order): void => {
console.log(data);
setPaymentModal(false);
};
/**
* Change cart's customer by admin/manger
*/
const handleChangeMember = (userId: number): void => {
CartAPI.setCustomer(cart, userId).then(data => {
setCart(data);
});
};
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
};
return (
@ -79,10 +115,24 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
</FabButton>
</div>
))}
{cart && <p>Totale: {FormatLib.price(cart.amount)}</p>}
<FabButton className="checkout-btn" onClick={checkout}>
{t('app.public.store_cart.checkout')}
</FabButton>
{cart && cart.order_items_attributes.length > 0 && <p>Totale: {FormatLib.price(cart.amount)}</p>}
{cart && isPrivileged() && <MemberSelect defaultUser={cart.user} onSelected={handleChangeMember} />}
{cart &&
<FabButton className="checkout-btn" onClick={checkout} disabled={!cart.user || cart.order_items_attributes.length === 0}>
{t('app.public.store_cart.checkout')}
</FabButton>
}
{cart && cart.order_items_attributes.length > 0 && cart.user && <div>
<PaymentModal isOpen={paymentModal}
toggleModal={togglePaymentModal}
afterSuccess={handlePaymentSuccess}
onError={onError}
cart={{ customer_id: currentUser.id, items: [], payment_method: PaymentMethod.Card }}
order={cart}
operator={currentUser}
customer={cart.user}
updateCart={() => console.log('success')} />
</div>}
</div>
);
};

View File

@ -18,16 +18,18 @@ import { GoogleTagManager } from '../../models/gtm';
import { ComputePriceResult } from '../../models/price';
import { Wallet } from '../../models/wallet';
import FormatLib from '../../lib/format';
import { Order } from '../../models/order';
export interface GatewayFormProps {
onSubmit: () => void,
onSuccess: (result: Invoice|PaymentSchedule) => void,
onSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: PaymentSchedule,
cart?: ShoppingCart,
order?: Order,
updateCart?: (cart: ShoppingCart) => void,
formId: string,
}
@ -35,9 +37,10 @@ export interface GatewayFormProps {
interface AbstractPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart?: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
@ -61,7 +64,7 @@ declare const GTM: GoogleTagManager;
* This component must not be called directly but must be extended for each implemented payment gateway.
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize, order }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
@ -108,16 +111,25 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* - Refresh the remaining price
*/
useEffect(() => {
if (!cart) return;
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
if (order && order?.user?.id) {
WalletAPI.getByUser(order.user.id).then((wallet) => {
setWallet(wallet);
const p = { price: order.amount, price_without_coupon: order.amount };
setPrice(p);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(p.price));
setReady(true);
});
});
}, [cart]);
} else if (cart && cart.customer_id) {
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
setRemainingPrice(new WalletLib(wallet).computeRemainingPrice(res.price));
setReady(true);
});
});
}
}, [cart, order]);
/**
* Check if there is currently an error to display
@ -157,7 +169,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
/**
* After sending the form with success, process the resulting payment method
*/
const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise<void> => {
const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise<void> => {
setSubmitState(false);
GTM.trackPurchase(result.id, result.total);
afterSuccess(result);
@ -213,6 +225,7 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
className={`gateway-form ${formClassName || ''}`}
formId={formId}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
paymentSchedule={schedule}>

View File

@ -11,15 +11,17 @@ import { Setting, SettingName } from '../../models/setting';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { useTranslation } from 'react-i18next';
import { Order } from '../../models/order';
declare const Application: IApplication;
interface CardPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -29,7 +31,7 @@ interface CardPaymentModalProps {
* 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 CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => {
const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => {
const { t } = useTranslation('shared');
const [gateway, setGateway] = useState<Setting>(null);
@ -49,6 +51,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={currentUser}
schedule={schedule}
customer={customer} />;
@ -63,6 +66,7 @@ const CardPaymentModal: React.FC<CardPaymentModalProps> = ({ isOpen, toggleModal
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={currentUser}
schedule={schedule}
customer={customer} />;
@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC<CardPaymentModalProps> = (props) => {
export { CardPaymentModalWrapper as CardPaymentModal };
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('cardPaymentModal', react2angular(CardPaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer', 'order']));

View File

@ -9,6 +9,7 @@ import { SettingName } from '../../../models/setting';
import { CardPaymentModal } from '../card-payment-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { HtmlTranslate } from '../../base/html-translate';
import CheckoutAPI from '../../../api/checkout';
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
@ -24,7 +25,7 @@ type selectOption = { value: scheduleMethod, label: string };
* This is intended for use by privileged users.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => {
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => {
const { t } = useTranslation('admin');
const [method, setMethod] = useState<scheduleMethod>('check');
@ -86,8 +87,13 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
}
try {
const document = await LocalPaymentAPI.confirmPayment(cart);
onSuccess(document);
let res;
if (order) {
res = await CheckoutAPI.payment(order.token);
} else {
res = await LocalPaymentAPI.confirmPayment(cart);
}
onSuccess(res);
} catch (e) {
onError(e);
}

View File

@ -10,15 +10,17 @@ import { ModalSize } from '../../base/fab-modal';
import { Loader } from '../../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../../models/application';
import { Order } from '../../../models/order';
declare const Application: IApplication;
interface LocalPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
@ -28,7 +30,7 @@ interface LocalPaymentModalProps {
/**
* This component enables a privileged user to confirm a local payments.
*/
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => {
const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => {
const { t } = useTranslation('admin');
/**
@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
/**
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => {
return (
<LocalPaymentForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -63,6 +65,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
className={className}
formId={formId}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
paymentSchedule={paymentSchedule}>
@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleMod
formClassName="local-payment-form"
currentUser={currentUser}
cart={cart}
order={order}
updateCart={updateCart}
customer={customer}
afterSuccess={afterSuccess}

View File

@ -12,6 +12,8 @@ import {
} from '../../../models/payzen';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { Invoice } from '../../../models/invoice';
import CheckoutAPI from '../../../api/checkout';
import { Order } from '../../../models/order';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
@ -22,7 +24,7 @@ interface PayzenFormProps extends GatewayFormProps {
* 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<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => {
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => {
const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
@ -44,7 +46,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
.catch(error => onError(error));
}).catch(error => onError(error));
});
}, [cart, paymentSchedule, customer]);
}, [cart, paymentSchedule, customer, order]);
/**
* Ask the API to create the form token.
@ -55,6 +57,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
return await PayzenAPI.updateToken(paymentSchedule?.id);
} else if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cart, customer);
} else if (order) {
const res = await CheckoutAPI.payment(order.token);
return res.payment as CreateTokenResponse;
} else {
return await PayzenAPI.chargeCreatePayment(cart, customer);
}
@ -88,9 +93,12 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
/**
* Confirm the payment, depending on the current type of payment (single shot or recurring)
*/
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule> => {
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule|Order> => {
if (paymentSchedule) {
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
} else if (order) {
const res = await CheckoutAPI.confirmPayment(order.token, event.clientAnswer.orderDetails.orderId);
return res.order;
} else {
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
}
@ -132,7 +140,9 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
try {
const { result } = await PayZenKR.current.validateForm();
if (result === null) {
await PayzenAPI.checkCart(cart, customer);
if (!order) {
await PayzenAPI.checkCart(cart, customer);
}
await PayZenKR.current.onSubmit(onPaid);
await PayZenKR.current.onError(handleError);
await PayZenKR.current.submit();

View File

@ -9,13 +9,15 @@ import payzenLogo from '../../../../../images/payzen-secure.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { PayzenForm } from './payzen-form';
import { Order } from '../../../models/order';
interface PayzenModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -28,7 +30,7 @@ interface PayzenModalProps {
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -45,7 +47,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
/**
* Integrates the PayzenForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
return (
<PayzenForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -54,6 +56,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
operator={operator}
formId={formId}
cart={cart}
order={order}
className={className}
paymentSchedule={paymentSchedule}>
{children}
@ -70,6 +73,7 @@ export const PayzenModal: React.FC<PayzenModalProps> = ({ isOpen, toggleModal, a
className="payzen-modal"
currentUser={currentUser}
cart={cart}
order={order}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}

View File

@ -11,13 +11,15 @@ import { LocalPaymentModal } from '../local-payment/local-payment-modal';
import { CardPaymentModal } from '../card-payment-modal';
import PriceAPI from '../../../api/price';
import { ComputePriceResult } from '../../../models/price';
import { Order } from '../../../models/order';
interface PaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
updateCart: (cart: ShoppingCart) => void,
operator: User,
schedule?: PaymentSchedule,
@ -27,7 +29,7 @@ interface PaymentModalProps {
/**
* This component is responsible for rendering the payment modal.
*/
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => {
export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => {
// the user's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// the price of the cart
@ -44,10 +46,14 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
// refresh the price when the cart changes
useEffect(() => {
PriceAPI.compute(cart).then(price => {
setPrice(price);
});
}, [cart]);
if (order) {
setPrice({ price: order.amount, price_without_coupon: order.amount });
} else {
PriceAPI.compute(cart).then(price => {
setPrice(price);
});
}
}, [cart, order]);
// refresh the remaining price when the cart price was computed and the wallet was retrieved
useEffect(() => {
@ -73,6 +79,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
updateCart={updateCart}
currentUser={operator}
customer={customer}
@ -86,6 +93,7 @@ export const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal,
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
order={order}
currentUser={operator}
customer={customer}
schedule={schedule}

View File

@ -6,12 +6,14 @@ import { PaymentConfirmation } from '../../../models/payment';
import StripeAPI from '../../../api/stripe';
import { Invoice } from '../../../models/invoice';
import { PaymentSchedule } from '../../../models/payment-schedule';
import CheckoutAPI from '../../../api/checkout';
import { Order } from '../../../models/order';
/**
* 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 StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => {
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId, order }) => {
const { t } = useTranslation('shared');
const stripe = useStripe();
@ -41,9 +43,19 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
} else {
try {
if (!paymentSchedule) {
// process the normal payment pipeline, including SCA validation
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
if (order) {
const res = await CheckoutAPI.payment(order.token, paymentMethod.id);
if (res.payment) {
await handleServerConfirmation(res.payment as PaymentConfirmation);
} else {
res.order.total = res.order.amount;
await handleServerConfirmation(res.order);
}
} else {
// process the normal payment pipeline, including SCA validation
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
}
} else {
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
await handleServerConfirmation(res, paymentMethod.id);
@ -61,7 +73,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
* @param paymentMethodId ID of the payment method, required only when confirming a payment schedule
* @see app/controllers/api/stripe_controller.rb#confirm_payment
*/
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => {
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule|Order, paymentMethodId?: string) => {
if ('error' in response) {
if (response.error.statusText) {
onError(response.error.statusText);
@ -78,8 +90,14 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
if (order) {
const confirmation = await CheckoutAPI.confirmPayment(order.token, result.paymentIntent.id);
confirmation.order.total = confirmation.order.amount;
await handleServerConfirmation(confirmation.order);
} else {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
}
} catch (e) {
onError(e);
}

View File

@ -10,13 +10,15 @@ import stripeLogo from '../../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { Invoice } from '../../../models/invoice';
import { Order } from '../../../models/order';
interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
afterSuccess: (result: Invoice|PaymentSchedule|Order) => void,
onError: (message: string) => void,
cart: ShoppingCart,
order?: Order,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -29,7 +31,7 @@ interface StripeModalProps {
* This component should not be called directly. Prefer using <CardPaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer, order }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -47,7 +49,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
/**
* Integrates the StripeForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => {
return (
<StripeElements>
<StripeForm onSubmit={onSubmit}
@ -57,6 +59,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
className={className}
formId={formId}
cart={cart}
order={order}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
@ -74,6 +77,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
formClassName="stripe-form"
currentUser={currentUser}
cart={cart}
order={order}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}

View File

@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react';
import AsyncSelect from 'react-select/async';
import { useTranslation } from 'react-i18next';
import MemberAPI from '../../api/member';
import { User } from '../../models/user';
interface MemberSelectProps {
defaultUser?: User,
onSelected?: (userId: number) => void
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
/**
* This component renders the member select for manager.
*/
export const MemberSelect: React.FC<MemberSelectProps> = ({ defaultUser, onSelected }) => {
const { t } = useTranslation('public');
const [value, setValue] = useState<selectOption>();
useEffect(() => {
if (defaultUser) {
setValue({ value: defaultUser.id, label: defaultUser.name });
}
}, []);
/**
* search members by name
*/
const loadMembers = async (inputValue: string): Promise<Array<selectOption>> => {
if (!inputValue) {
return [];
}
const data = await MemberAPI.search(inputValue);
return data.map(u => {
return { value: u.id, label: u.name };
});
};
/**
* callback for handle select changed
*/
const onChange = (v: selectOption) => {
setValue(v);
onSelected(v.value);
};
return (
<div className="member-select">
<div className="member-select-header">
<h3 className="member-select-title">{t('app.public.member_select.select_a_member')}</h3>
</div>
<AsyncSelect placeholder={t('app.public.member_select.start_typing')}
cacheOptions
loadOptions={loadMembers}
defaultOptions
onChange={onChange}
value={value}
/>
</div>
);
};

View File

@ -1,13 +1,18 @@
import { TDateISO } from '../typings/date-iso';
import { PaymentConfirmation } from './payment';
import { CreateTokenResponse } from './payzen';
import { User } from './user';
export interface Order {
id: number,
token: string,
statistic_profile_id?: number,
user?: User,
operator_id?: number,
reference?: string,
state?: string,
amount?: number,
total?: number,
created_at?: TDateISO,
order_items_attributes: Array<{
id: number,
@ -19,3 +24,8 @@ export interface Order {
is_offered: boolean
}>,
}
export interface OrderPayment {
order: Order,
payment?: PaymentConfirmation|CreateTokenResponse
}

View File

@ -8,5 +8,8 @@ class Order < ApplicationRecord
ALL_STATES = %w[cart].freeze
enum state: ALL_STATES.zip(ALL_STATES).to_h
PAYMENT_STATES = %w[paid failed].freeze
enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h
validates :token, :state, presence: true
end

View File

@ -6,6 +6,10 @@ class CartPolicy < ApplicationPolicy
true
end
def set_customer?
user.privileged?
end
%w[add_item remove_item set_quantity].each do |action|
define_method "#{action}?" do
return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Provides methods for admin set customer to order
class Cart::SetCustomerService
def call(order, user_id)
user = User.find(user_id)
order.update(statistic_profile_id: user.statistic_profile.id)
order.reload
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
# Provides methods for pay cart
class Checkout::PaymentService
require 'pay_zen/helper'
require 'stripe/helper'
def payment(order, operator, payment_id = '')
if operator.member?
if Stripe::Helper.enabled?
Payments::StripeService.new.payment(order, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.payment(order)
else
raise Error('Bad gateway or online payment is disabled')
end
elsif operator.privileged?
Payments::LocalService.new.payment(order)
end
end
def confirm_payment(order, operator, payment_id = '')
if operator.member?
if Stripe::Helper.enabled?
Payments::StripeService.new.confirm_payment(order, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.confirm_payment(order, payment_id)
else
raise Error('Bad gateway or online payment is disabled')
end
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
# Provides methods for pay cart by Local
class Payments::LocalService
include Payments::PaymentConcern
def payment(order)
o = payment_success(order)
{ order: o }
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# Concern for Payment
module Payments::PaymentConcern
private
def get_wallet_debit(user, total_amount)
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total_amount ? total_amount : wallet_amount
end
def debit_amount(order)
total = order.amount
wallet_debit = get_wallet_debit(order.statistic_profile.user, total)
total - wallet_debit
end
def payment_success(order)
ActiveRecord::Base.transaction do
WalletService.debit_user_wallet(order, order.statistic_profile.user)
order.update(payment_state: 'paid')
order.order_items.each do |item|
ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity)
end
order.reload
end
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
# Provides methods for pay cart by PayZen
class Payments::PayzenService
require 'pay_zen/helper'
require 'pay_zen/order'
require 'pay_zen/charge'
require 'pay_zen/service'
include Payments::PaymentConcern
def payment(order)
amount = debit_amount(order)
id = PayZen::Helper.generate_ref(order, order.statistic_profile.user.id)
client = PayZen::Charge.new
result = client.create_payment(amount: PayZen::Service.new.payzen_amount(amount),
order_id: id,
customer: PayZen::Helper.generate_customer(order.statistic_profile.user.id,
order.statistic_profile.user.id, order))
{ order: order, payment: { formToken: result['answer']['formToken'], orderId: id } }
end
def confirm_payment(order, payment_id)
client = PayZen::Order.new
payzen_order = client.get(payment_id, operation_type: 'DEBIT')
if payzen_order['answer']['transactions'].any? { |transaction| transaction['status'] == 'PAID' }
o = payment_success(order)
{ order: o }
else
order.update(payment_state: 'failed')
{ order: order, payment_error: payzen_order['answer'] }
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
# Provides methods for pay cart by Stripe
class Payments::StripeService
require 'stripe/service'
include Payments::PaymentConcern
def payment(order, payment_id)
amount = debit_amount(order)
# Create the PaymentIntent
intent = Stripe::PaymentIntent.create(
{
payment_method: payment_id,
amount: Stripe::Service.new.stripe_amount(amount),
currency: Setting.get('stripe_currency'),
confirmation_method: 'manual',
confirm: true,
customer: order.statistic_profile.user.payment_gateway_object.gateway_object_id
}, { api_key: Setting.get('stripe_secret_key') }
)
if intent&.status == 'succeeded'
o = payment_success(order)
return { order: o }
end
if intent&.status == 'requires_action' && intent&.next_action&.type == 'use_stripe_sdk'
{ order: order, payment: { requires_action: true, payment_intent_client_secret: intent.client_secret,
type: 'payment' } }
end
end
def confirm_payment(order, payment_id)
intent = Stripe::PaymentIntent.confirm(payment_id, {}, { api_key: Setting.get('stripe_secret_key') })
if intent&.status == 'succeeded'
o = payment_success(order)
{ order: o }
else
order.update(payment_state: 'failed')
{ order: order, payment_error: 'payment failed' }
end
end
end

View File

@ -22,4 +22,12 @@ class ProductService
end
nil
end
def self.update_stock(product, stock_type, reason, quantity)
remaining_stock = product.stock[stock_type] + quantity
product.product_stock_movements.create(stock_type: stock_type, reason: reason, quantity: quantity, remaining_stock: remaining_stock,
date: DateTime.current)
product.stock[stock_type] = remaining_stock
product.save
end
end

View File

@ -82,6 +82,8 @@ class WalletService
def self.wallet_amount_debit(payment, user, coupon = nil)
total = if payment.is_a? PaymentSchedule
payment.payment_schedule_items.first.amount
elsif payment.is_a? Order
payment.amount
else
payment.total
end
@ -106,10 +108,9 @@ class WalletService
# wallet debit success
raise DebitWalletError unless wallet_transaction
payment.set_wallet_transaction(wallet_amount, wallet_transaction.id)
payment.set_wallet_transaction(wallet_amount, wallet_transaction.id) unless payment.is_a? Order
else
payment.set_wallet_transaction(wallet_amount, nil)
payment.set_wallet_transaction(wallet_amount, nil) unless payment.is_a? Order
end
end
end

View File

@ -2,6 +2,11 @@
json.extract! order, :id, :token, :statistic_profile_id, :operator_id, :reference, :state, :created_at
json.amount order.amount / 100.0 if order.amount.present?
json.user do
json.extract! order&.statistic_profile&.user, :id
json.role order&.statistic_profile&.user&.roles&.first&.name
json.name order&.statistic_profile&.user&.profile&.full_name
end
json.order_items_attributes order.order_items do |item|
json.id item.id

View File

@ -159,6 +159,11 @@ Rails.application.routes.draw do
put 'add_item', on: :collection
put 'remove_item', on: :collection
put 'set_quantity', on: :collection
put 'set_customer', on: :collection
end
resources :checkout, only: %i[] do
post 'payment', on: :collection
post 'confirm_payment', on: :collection
end
# for admin

View File

@ -0,0 +1,5 @@
class AddPaymentStateToOrder < ActiveRecord::Migration[5.2]
def change
add_column :orders, :payment_state, :string
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_08_18_160821) do
ActiveRecord::Schema.define(version: 2022_08_22_081222) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -467,6 +467,7 @@ ActiveRecord::Schema.define(version: 2022_08_18_160821) do
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "payment_state"
t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id"
end

View File

@ -59,10 +59,24 @@ class PayZen::Helper
def generate_shopping_cart(cart_items, customer, operator)
cart = if cart_items.is_a? ShoppingCart
cart_items
elsif cart_items.is_a? Order
cart_items
else
cs = CartService.new(operator)
cs.from_hash(cart_items)
end
if cart.is_a? Order
return {
cartItemInfo: cart.order_items.map do |item|
{
productAmount: item.amount.to_i.to_s,
productLabel: item.orderable_id,
productQty: item.quantity.to_s,
productType: customer.organization? ? 'SERVICE_FOR_BUSINESS' : 'SERVICE_FOR_INDIVIDUAL'
}
end
}
end
{
cartItemInfo: cart.items.map do |item|
{