mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-20 14:54:15 +01:00
Merge branch 'product_store-payment' into product_store-store
This commit is contained in:
commit
218a9cbddc
@ -9,11 +9,20 @@ 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?
|
||||
@order = Order.find_by(token: order_token, state: 'cart')
|
||||
if @order.nil?
|
||||
if current_user&.member?
|
||||
@order = Order.where(statistic_profile_id: current_user.statistic_profile.id,
|
||||
state: 'cart').last
|
||||
end
|
||||
if current_user&.privileged?
|
||||
@order = Order.where(operator_profile_id: current_user.invoicing_profile.id,
|
||||
state: 'cart').last
|
||||
end
|
||||
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?
|
||||
@order.update(operator_profile_id: current_user.invoicing_profile.id) if @order.operator_profile_id.nil? && current_user&.privileged?
|
||||
end
|
||||
@order ||= Cart::CreateService.new.call(current_user)
|
||||
render 'api/orders/show'
|
||||
@ -37,13 +46,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
|
||||
|
@ -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
|
||||
|
@ -9,10 +9,14 @@ module API::OrderConcern
|
||||
end
|
||||
|
||||
def current_order
|
||||
@current_order = Order.find_by(token: order_token)
|
||||
@current_order = Order.find_by(token: order_token, state: 'cart')
|
||||
end
|
||||
|
||||
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
|
||||
|
5
app/exceptions/cart/zero_price_error.rb
Normal file
5
app/exceptions/cart/zero_price_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when order amount = 0
|
||||
class Cart::ZeroPriceError < StandardError
|
||||
end
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
21
app/frontend/src/javascript/api/checkout.ts
Normal file
21
app/frontend/src/javascript/api/checkout.ts
Normal 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;
|
||||
}
|
||||
}
|
10
app/frontend/src/javascript/api/coupon.ts
Normal file
10
app/frontend/src/javascript/api/coupon.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Coupon } from '../models/coupon';
|
||||
|
||||
export default class CouponAPI {
|
||||
static async validate (code: string, amount: number, userId?: number): Promise<Coupon> {
|
||||
const res: AxiosResponse<Coupon> = await apiClient.post('/api/coupons/validate', { code, amount, user_id: userId });
|
||||
return res?.data;
|
||||
}
|
||||
}
|
@ -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]) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
@ -8,12 +8,17 @@ 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';
|
||||
import { CouponInput } from '../coupon/coupon-input';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
interface StoreCartProps {
|
||||
onError: (message: string) => void,
|
||||
currentUser: User,
|
||||
currentUser?: User,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -22,13 +27,8 @@ interface StoreCartProps {
|
||||
const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const { cart, setCart, reloadCart } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
reloadCart();
|
||||
}
|
||||
}, [currentUser]);
|
||||
const { cart, setCart } = useCart(currentUser);
|
||||
const [paymentModal, setPaymentModal] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Remove the product from cart
|
||||
@ -39,7 +39,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
|
||||
e.stopPropagation();
|
||||
CartAPI.removeItem(cart, item.orderable_id).then(data => {
|
||||
setCart(data);
|
||||
});
|
||||
}).catch(onError);
|
||||
};
|
||||
};
|
||||
|
||||
@ -50,7 +50,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onError, currentUser }) => {
|
||||
return (e: React.BaseSyntheticEvent) => {
|
||||
CartAPI.setQuantity(cart, item.orderable_id, e.target.value).then(data => {
|
||||
setCart(data);
|
||||
});
|
||||
}).catch(onError);
|
||||
};
|
||||
};
|
||||
|
||||
@ -58,11 +58,54 @@ 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 => {
|
||||
if (data.payment_state === 'paid') {
|
||||
setPaymentModal(false);
|
||||
window.location.href = '/#!/store';
|
||||
} else {
|
||||
onError('Erreur inconnue after payment, please conntact admin');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Change cart's customer by admin/manger
|
||||
*/
|
||||
const handleChangeMember = (userId: number): void => {
|
||||
CartAPI.setCustomer(cart, userId).then(data => {
|
||||
setCart(data);
|
||||
}).catch(onError);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current operator has administrative rights or is a normal member
|
||||
*/
|
||||
const isPrivileged = (): boolean => {
|
||||
return (currentUser?.role === 'admin' || currentUser?.role === 'manager');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current cart is empty ?
|
||||
*/
|
||||
const cartIsEmpty = (): boolean => {
|
||||
return cart && cart.order_items_attributes.length === 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-cart">
|
||||
{cart && cartIsEmpty() && <p>{t('app.public.store_cart.cart_is_empty')}</p>}
|
||||
{cart && cart.order_items_attributes.map(item => (
|
||||
<div key={item.id}>
|
||||
<div>{item.orderable_name}</div>
|
||||
@ -79,10 +122,25 @@ 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 && !cartIsEmpty() && <CouponInput user={cart.user} amount={cart.total} />}
|
||||
{cart && !cartIsEmpty() && <p>Totale: {FormatLib.price(cart.total)}</p>}
|
||||
{cart && !cartIsEmpty() && isPrivileged() && <MemberSelect defaultUser={cart.user} onSelected={handleChangeMember} />}
|
||||
{cart && !cartIsEmpty() &&
|
||||
<FabButton className="checkout-btn" onClick={checkout} disabled={!cart.user || cart.order_items_attributes.length === 0}>
|
||||
{t('app.public.store_cart.checkout')}
|
||||
</FabButton>
|
||||
}
|
||||
{cart && !cartIsEmpty() && cart.user && <div>
|
||||
<PaymentModal isOpen={paymentModal}
|
||||
toggleModal={togglePaymentModal}
|
||||
afterSuccess={handlePaymentSuccess}
|
||||
onError={onError}
|
||||
cart={{ customer_id: cart.user.id, items: [], payment_method: PaymentMethod.Card }}
|
||||
order={cart}
|
||||
operator={currentUser}
|
||||
customer={cart.user}
|
||||
updateCart={() => 'dont need update shopping cart'} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabInput } from '../base/fab-input';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import CouponAPI from '../../api/coupon';
|
||||
import { Coupon } from '../../models/coupon';
|
||||
import { User } from '../../models/user';
|
||||
|
||||
interface CouponInputProps {
|
||||
amount: number,
|
||||
user?: User,
|
||||
onChange?: (coupon: Coupon) => void
|
||||
}
|
||||
|
||||
interface Message {
|
||||
type: 'info' | 'warning' | 'danger',
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This component renders an input of coupon
|
||||
*/
|
||||
export const CouponInput: React.FC<CouponInputProps> = ({ user, amount, onChange }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
const [messages, setMessages] = useState<Array<Message>>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [coupon, setCoupon] = useState<Coupon>();
|
||||
|
||||
/**
|
||||
* callback for validate the code
|
||||
*/
|
||||
const handleChange = (value: string) => {
|
||||
const mgs = [];
|
||||
setMessages([]);
|
||||
setError(false);
|
||||
setCoupon(null);
|
||||
if (value) {
|
||||
setLoading(true);
|
||||
CouponAPI.validate(value, amount, user?.id).then((res) => {
|
||||
setCoupon(res);
|
||||
if (res.type === 'percent_off') {
|
||||
mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
|
||||
} else {
|
||||
mgs.push({ type: 'info', message: t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: 'euro' }) });
|
||||
}
|
||||
if (res.validity_per_user === 'once') {
|
||||
mgs.push({ type: 'warning', message: t('app.shared.coupon_input.coupon_validity_once') });
|
||||
}
|
||||
setMessages(mgs);
|
||||
setLoading(false);
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(res);
|
||||
}
|
||||
}).catch((err) => {
|
||||
const state = err.split(':')[1].trim();
|
||||
setError(true);
|
||||
setCoupon(null);
|
||||
setLoading(false);
|
||||
setMessages([{ type: 'danger', message: t(`app.shared.coupon_input.unable_to_apply_the_coupon_because_${state}`) }]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// input addon
|
||||
const inputAddOn = () => {
|
||||
if (error) {
|
||||
return <i className="fa fa-times" />;
|
||||
} else {
|
||||
if (loading) {
|
||||
return <i className="fa fa-spinner fa-pulse fa-fw" />;
|
||||
}
|
||||
if (coupon) {
|
||||
return <i className="fa fa-check" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="coupon-input">
|
||||
<label htmlFor="coupon-input_input">{t('app.shared.coupon_input.i_have_a_coupon')}</label>
|
||||
<FabInput id="coupon-input_input"
|
||||
type="text"
|
||||
addOn={inputAddOn()}
|
||||
debounce={500}
|
||||
onChange={handleChange} />
|
||||
{messages.map((m, i) => {
|
||||
return (
|
||||
<FabAlert key={i} level={m.type}>
|
||||
{m.message}
|
||||
</FabAlert>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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.total, price_without_coupon: order.total };
|
||||
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}>
|
||||
|
@ -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']));
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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();
|
||||
|
@ -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}
|
||||
|
@ -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.total, price_without_coupon: order.total });
|
||||
} 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}
|
||||
|
@ -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,18 @@ 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 {
|
||||
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 +72,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 +89,13 @@ 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);
|
||||
await handleServerConfirmation(confirmation.order);
|
||||
} else {
|
||||
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
|
||||
await handleServerConfirmation(confirmation);
|
||||
}
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -24,7 +24,7 @@ interface StoreProps {
|
||||
const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const { cart, setCart, reloadCart } = useCart();
|
||||
const { cart, setCart } = useCart(currentUser);
|
||||
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
|
||||
@ -40,12 +40,6 @@ const Store: React.FC<StoreProps> = ({ onError, currentUser }) => {
|
||||
emitCustomEvent('CartUpdate', cart);
|
||||
}, [cart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
reloadCart();
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
return (
|
||||
<div className="store">
|
||||
<div className='layout'>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -2,8 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
import { Order } from '../models/order';
|
||||
import CartAPI from '../api/cart';
|
||||
import { getCartToken, setCartToken } from '../lib/cart-token';
|
||||
import { User } from '../models/user';
|
||||
|
||||
export default function useCart () {
|
||||
export default function useCart (user?: User) {
|
||||
const [cart, setCart] = useState<Order>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState(null);
|
||||
@ -33,5 +34,11 @@ export default function useCart () {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && cart && (!cart.statistic_profile_id || !cart.operator_id)) {
|
||||
reloadCart();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return { loading, cart, error, setCart, reloadCart };
|
||||
}
|
||||
|
8
app/frontend/src/javascript/models/coupon.ts
Normal file
8
app/frontend/src/javascript/models/coupon.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Coupon {
|
||||
id: number,
|
||||
code: string,
|
||||
type: string,
|
||||
amount_off: number,
|
||||
percent_off: number,
|
||||
validity_per_user: string
|
||||
}
|
@ -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,
|
||||
operator_id?: number,
|
||||
user?: User,
|
||||
operator_profile_id?: number,
|
||||
reference?: string,
|
||||
state?: string,
|
||||
amount?: number,
|
||||
payment_state?: string,
|
||||
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
|
||||
}
|
||||
|
@ -3,10 +3,19 @@
|
||||
# Order is a model for the user hold information of order
|
||||
class Order < ApplicationRecord
|
||||
belongs_to :statistic_profile
|
||||
belongs_to :operator_profile, class_name: 'InvoicingProfile'
|
||||
has_many :order_items, dependent: :destroy
|
||||
|
||||
ALL_STATES = %w[cart].freeze
|
||||
ALL_STATES = %w[cart in_progress ready canceled return].freeze
|
||||
enum state: ALL_STATES.zip(ALL_STATES).to_h
|
||||
|
||||
PAYMENT_STATES = %w[paid failed refunded].freeze
|
||||
enum payment_state: PAYMENT_STATES.zip(PAYMENT_STATES).to_h
|
||||
|
||||
validates :token, :state, presence: true
|
||||
|
||||
def set_wallet_transaction(amount, transaction_id)
|
||||
self.wallet_amount = amount
|
||||
self.wallet_transaction_id = transaction_id
|
||||
end
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -15,7 +15,7 @@ class Cart::AddItemService
|
||||
else
|
||||
item.quantity += quantity.to_i
|
||||
end
|
||||
order.amount += (orderable.amount * quantity.to_i)
|
||||
order.total += (orderable.amount * quantity.to_i)
|
||||
ActiveRecord::Base.transaction do
|
||||
item.save
|
||||
order.save
|
||||
|
@ -7,12 +7,12 @@ class Cart::CreateService
|
||||
order_param = {
|
||||
token: token,
|
||||
state: 'cart',
|
||||
amount: 0
|
||||
total: 0
|
||||
}
|
||||
if user
|
||||
order_param[:statistic_profile_id] = user.statistic_profile.id if user.member?
|
||||
|
||||
order_param[:operator_id] = user.id if user.privileged?
|
||||
order_param[:operator_profile_id] = user.invoicing_profile.id if user.privileged?
|
||||
end
|
||||
Order.create!(order_param)
|
||||
end
|
||||
|
@ -7,7 +7,7 @@ class Cart::RemoveItemService
|
||||
|
||||
raise ActiveRecord::RecordNotFound if item.nil?
|
||||
|
||||
order.amount -= (item.amount * item.quantity.to_i)
|
||||
order.total -= (item.amount * item.quantity.to_i)
|
||||
ActiveRecord::Base.transaction do
|
||||
item.destroy!
|
||||
order.save
|
||||
|
10
app/services/cart/set_customer_service.rb
Normal file
10
app/services/cart/set_customer_service.rb
Normal 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
|
@ -12,7 +12,7 @@ class Cart::SetQuantityService
|
||||
raise ActiveRecord::RecordNotFound if item.nil?
|
||||
|
||||
different_quantity = quantity.to_i - item.quantity
|
||||
order.amount += (orderable.amount * different_quantity)
|
||||
order.total += (orderable.amount * different_quantity)
|
||||
ActiveRecord::Base.transaction do
|
||||
item.update(quantity: quantity.to_i)
|
||||
order.save
|
||||
|
37
app/services/checkout/payment_service.rb
Normal file
37
app/services/checkout/payment_service.rb
Normal file
@ -0,0 +1,37 @@
|
||||
# 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 = '')
|
||||
raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external')
|
||||
|
||||
raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active?(order)
|
||||
|
||||
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
|
18
app/services/orders/order_service.rb
Normal file
18
app/services/orders/order_service.rb
Normal file
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides methods for Order
|
||||
class Orders::OrderService
|
||||
def in_stock?(order, stock_type = 'external')
|
||||
order.order_items.each do |item|
|
||||
return false if item.orderable.stock[stock_type] < item.quantity
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def all_products_is_active?(order)
|
||||
order.order_items.each do |item|
|
||||
return false unless item.orderable.is_active
|
||||
end
|
||||
true
|
||||
end
|
||||
end
|
11
app/services/payments/local_service.rb
Normal file
11
app/services/payments/local_service.rb
Normal 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, 'local')
|
||||
{ order: o }
|
||||
end
|
||||
end
|
28
app/services/payments/payment_concern.rb
Normal file
28
app/services/payments/payment_concern.rb
Normal 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.total
|
||||
wallet_debit = get_wallet_debit(order.statistic_profile.user, total)
|
||||
total - wallet_debit
|
||||
end
|
||||
|
||||
def payment_success(order, payment_method = '')
|
||||
ActiveRecord::Base.transaction do
|
||||
WalletService.debit_user_wallet(order, order.statistic_profile.user)
|
||||
order.update(state: 'in_progress', payment_state: 'paid', payment_method: payment_method)
|
||||
order.order_items.each do |item|
|
||||
ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity, item.id)
|
||||
end
|
||||
order.reload
|
||||
end
|
||||
end
|
||||
end
|
38
app/services/payments/payzen_service.rb
Normal file
38
app/services/payments/payzen_service.rb
Normal file
@ -0,0 +1,38 @@
|
||||
# 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)
|
||||
|
||||
raise Cart::ZeroPriceError if amount.zero?
|
||||
|
||||
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, 'card')
|
||||
{ order: o }
|
||||
else
|
||||
order.update(payment_state: 'failed')
|
||||
{ order: order, payment_error: payzen_order['answer'] }
|
||||
end
|
||||
end
|
||||
end
|
46
app/services/payments/stripe_service.rb
Normal file
46
app/services/payments/stripe_service.rb
Normal file
@ -0,0 +1,46 @@
|
||||
# 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)
|
||||
|
||||
raise Cart::ZeroPriceError if amount.zero?
|
||||
|
||||
# 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, 'card')
|
||||
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, 'card')
|
||||
{ order: o }
|
||||
else
|
||||
order.update(payment_state: 'failed')
|
||||
{ order: order, payment_error: 'payment failed' }
|
||||
end
|
||||
end
|
||||
end
|
@ -22,4 +22,13 @@ class ProductService
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def self.update_stock(product, stock_type, reason, quantity, order_item_id)
|
||||
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,
|
||||
order_item_id: order_item_id)
|
||||
product.stock[stock_type] = remaining_stock
|
||||
product.save
|
||||
end
|
||||
end
|
||||
|
@ -75,7 +75,7 @@ class WalletService
|
||||
|
||||
##
|
||||
# Compute the amount decreased from the user's wallet, if applicable
|
||||
# @param payment {Invoice|PaymentSchedule}
|
||||
# @param payment {Invoice|PaymentSchedule|Order}
|
||||
# @param user {User} the customer
|
||||
# @param coupon {Coupon|String} Coupon object or code
|
||||
##
|
||||
@ -93,7 +93,7 @@ class WalletService
|
||||
end
|
||||
|
||||
##
|
||||
# Subtract the amount of the payment document (Invoice|PaymentSchedule) from the customer's wallet
|
||||
# Subtract the amount of the payment document (Invoice|PaymentSchedule|Order) from the customer's wallet
|
||||
# @param transaction, if false: the wallet is not debited, the transaction is only simulated on the payment document
|
||||
##
|
||||
def self.debit_user_wallet(payment, user, transaction: true)
|
||||
@ -111,5 +111,4 @@ class WalletService
|
||||
payment.set_wallet_transaction(wallet_amount, nil)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -1,7 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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.extract! order, :id, :token, :statistic_profile_id, :operator_profile_id, :reference, :state, :created_at
|
||||
json.total order.total / 100.0 if order.total.present?
|
||||
if order&.statistic_profile&.user
|
||||
json.user do
|
||||
json.id order.statistic_profile.user.id
|
||||
json.role order.statistic_profile.user.roles.first.name
|
||||
json.name order.statistic_profile.user.profile.full_name
|
||||
end
|
||||
end
|
||||
|
||||
json.order_items_attributes order.order_items do |item|
|
||||
json.id item.id
|
||||
|
@ -391,6 +391,10 @@ en:
|
||||
my_cart: "My Cart"
|
||||
store_cart:
|
||||
checkout: "Checkout"
|
||||
cart_is_empty: "Your cart is empty"
|
||||
member_select:
|
||||
select_a_member: "Select a member"
|
||||
start_typing: "Start typing..."
|
||||
tour:
|
||||
conclusion:
|
||||
title: "Thank you for your attention"
|
||||
|
@ -391,6 +391,10 @@ fr:
|
||||
my_cart: "Mon Panier"
|
||||
store_cart:
|
||||
checkout: "Valider mon panier"
|
||||
cart_is_empty: "Votre panier est vide"
|
||||
member_select:
|
||||
select_a_member: "Sélectionnez un membre"
|
||||
start_typing: "Commencez à écrire..."
|
||||
tour:
|
||||
conclusion:
|
||||
title: "Merci de votre attention"
|
||||
|
@ -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
|
||||
|
5
db/migrate/20220822081222_add_payment_state_to_order.rb
Normal file
5
db/migrate/20220822081222_add_payment_state_to_order.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class AddPaymentStateToOrder < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :orders, :payment_state, :string
|
||||
end
|
||||
end
|
@ -0,0 +1,5 @@
|
||||
class RenameAmountToTotalInOrder < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
rename_column :orders, :amount, :total
|
||||
end
|
||||
end
|
@ -0,0 +1,5 @@
|
||||
class AddOrderItemIdToProductStockMovement < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :product_stock_movements, :order_item_id, :integer
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
class AddWalletAmountAndWalletTransactionIdToOrder < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :orders, :wallet_amount, :integer
|
||||
add_column :orders, :wallet_transaction_id, :integer
|
||||
end
|
||||
end
|
5
db/migrate/20220826091819_add_payment_method_to_order.rb
Normal file
5
db/migrate/20220826091819_add_payment_method_to_order.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class AddPaymentMethodToOrder < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :orders, :payment_method, :string
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
class RenameOperatorIdToOperatorProfileIdInOrder < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
rename_column :orders, :operator_id, :operator_profile_id
|
||||
add_index :orders, :operator_profile_id
|
||||
add_foreign_key :orders, :invoicing_profiles, column: :operator_profile_id, primary_key: :id
|
||||
end
|
||||
end
|
13
db/schema.rb
13
db/schema.rb
@ -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_26_093503) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "fuzzystrmatch"
|
||||
@ -460,13 +460,18 @@ ActiveRecord::Schema.define(version: 2022_08_18_160821) do
|
||||
|
||||
create_table "orders", force: :cascade do |t|
|
||||
t.bigint "statistic_profile_id"
|
||||
t.integer "operator_id"
|
||||
t.integer "operator_profile_id"
|
||||
t.string "token"
|
||||
t.string "reference"
|
||||
t.string "state"
|
||||
t.integer "amount"
|
||||
t.integer "total"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "payment_state"
|
||||
t.integer "wallet_amount"
|
||||
t.integer "wallet_transaction_id"
|
||||
t.string "payment_method"
|
||||
t.index ["operator_profile_id"], name: "index_orders_on_operator_profile_id"
|
||||
t.index ["statistic_profile_id"], name: "index_orders_on_statistic_profile_id"
|
||||
end
|
||||
|
||||
@ -631,6 +636,7 @@ ActiveRecord::Schema.define(version: 2022_08_18_160821) do
|
||||
t.datetime "date"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "order_item_id"
|
||||
t.index ["product_id"], name: "index_product_stock_movements_on_product_id"
|
||||
end
|
||||
|
||||
@ -1159,6 +1165,7 @@ ActiveRecord::Schema.define(version: 2022_08_18_160821) do
|
||||
add_foreign_key "invoices", "wallet_transactions"
|
||||
add_foreign_key "invoicing_profiles", "users"
|
||||
add_foreign_key "order_items", "orders"
|
||||
add_foreign_key "orders", "invoicing_profiles", column: "operator_profile_id"
|
||||
add_foreign_key "orders", "statistic_profiles"
|
||||
add_foreign_key "organizations", "invoicing_profiles"
|
||||
add_foreign_key "payment_gateway_objects", "payment_gateway_objects"
|
||||
|
@ -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|
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user