diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index f26d97469..95f30fe1e 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -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 diff --git a/app/controllers/api/checkout_controller.rb b/app/controllers/api/checkout_controller.rb index 4c9425747..3aeb661d4 100644 --- a/app/controllers/api/checkout_controller.rb +++ b/app/controllers/api/checkout_controller.rb @@ -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 diff --git a/app/controllers/concerns/api/order_concern.rb b/app/controllers/concerns/api/order_concern.rb index eb38d42d0..9e14854dd 100644 --- a/app/controllers/concerns/api/order_concern.rb +++ b/app/controllers/concerns/api/order_concern.rb @@ -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 diff --git a/app/exceptions/cart/zero_price_error.rb b/app/exceptions/cart/zero_price_error.rb new file mode 100644 index 000000000..7ac80e19f --- /dev/null +++ b/app/exceptions/cart/zero_price_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when order amount = 0 +class Cart::ZeroPriceError < StandardError +end diff --git a/app/frontend/src/javascript/api/cart.ts b/app/frontend/src/javascript/api/cart.ts index 4601de322..e3f0ebd16 100644 --- a/app/frontend/src/javascript/api/cart.ts +++ b/app/frontend/src/javascript/api/cart.ts @@ -22,4 +22,9 @@ export default class CartAPI { const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.put('/api/cart/set_customer', { order_token: order.token, user_id: userId }); + return res?.data; + } } diff --git a/app/frontend/src/javascript/api/checkout.ts b/app/frontend/src/javascript/api/checkout.ts new file mode 100644 index 000000000..ed9c41bb8 --- /dev/null +++ b/app/frontend/src/javascript/api/checkout.ts @@ -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 { + const res: AxiosResponse = await apiClient.post('/api/checkout/payment', { + order_token: token, + payment_id: paymentId + }); + return res?.data; + } + + static async confirmPayment (token: string, paymentId: string): Promise { + const res: AxiosResponse = await apiClient.post('/api/checkout/confirm_payment', { + order_token: token, + payment_id: paymentId + }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/coupon.ts b/app/frontend/src/javascript/api/coupon.ts new file mode 100644 index 000000000..5f0dd4fd8 --- /dev/null +++ b/app/frontend/src/javascript/api/coupon.ts @@ -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 { + const res: AxiosResponse = await apiClient.post('/api/coupons/validate', { code, amount, user_id: userId }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/member.ts b/app/frontend/src/javascript/api/member.ts index 0c3697c18..e22262a68 100644 --- a/app/frontend/src/javascript/api/member.ts +++ b/app/frontend/src/javascript/api/member.ts @@ -9,6 +9,16 @@ export default class MemberAPI { return res?.data; } + static async search (name: string): Promise> { + const res: AxiosResponse> = await apiClient.get(`/api/members/search/${name}`); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/members/${id}`); + return res?.data; + } + static async create (user: User): Promise { const data = serialize({ user }); if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) { diff --git a/app/frontend/src/javascript/components/cart/store-cart.tsx b/app/frontend/src/javascript/components/cart/store-cart.tsx index d24adffc2..aaf540f22 100644 --- a/app/frontend/src/javascript/components/cart/store-cart.tsx +++ b/app/frontend/src/javascript/components/cart/store-cart.tsx @@ -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 = ({ 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(false); /** * Remove the product from cart @@ -39,7 +39,7 @@ const StoreCart: React.FC = ({ onError, currentUser }) => { e.stopPropagation(); CartAPI.removeItem(cart, item.orderable_id).then(data => { setCart(data); - }); + }).catch(onError); }; }; @@ -50,7 +50,7 @@ const StoreCart: React.FC = ({ 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 = ({ 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 (
+ {cart && cartIsEmpty() &&

{t('app.public.store_cart.cart_is_empty')}

} {cart && cart.order_items_attributes.map(item => (
{item.orderable_name}
@@ -79,10 +122,25 @@ const StoreCart: React.FC = ({ onError, currentUser }) => {
))} - {cart &&

Totale: {FormatLib.price(cart.amount)}

} - - {t('app.public.store_cart.checkout')} - + {cart && !cartIsEmpty() && } + {cart && !cartIsEmpty() &&

Totale: {FormatLib.price(cart.total)}

} + {cart && !cartIsEmpty() && isPrivileged() && } + {cart && !cartIsEmpty() && + + {t('app.public.store_cart.checkout')} + + } + {cart && !cartIsEmpty() && cart.user &&
+ 'dont need update shopping cart'} /> +
}
); }; diff --git a/app/frontend/src/javascript/components/coupon/coupon-input.tsx b/app/frontend/src/javascript/components/coupon/coupon-input.tsx new file mode 100644 index 000000000..1e147e0ad --- /dev/null +++ b/app/frontend/src/javascript/components/coupon/coupon-input.tsx @@ -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 = ({ user, amount, onChange }) => { + const { t } = useTranslation('shared'); + const [messages, setMessages] = useState>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [coupon, setCoupon] = useState(); + + /** + * 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 ; + } else { + if (loading) { + return ; + } + if (coupon) { + return ; + } + } + }; + + return ( +
+ + + {messages.map((m, i) => { + return ( + + {m.message} + + ); + })} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index 2fc93aae3..5eab0f329 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -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 = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => { +export const AbstractPaymentModal: React.FC = ({ 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(null); // server-computed price with all details @@ -108,16 +111,25 @@ export const AbstractPaymentModal: React.FC = ({ 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 = ({ isOp /** * After sending the form with success, process the resulting payment method */ - const handleFormSuccess = async (result: Invoice|PaymentSchedule): Promise => { + const handleFormSuccess = async (result: Invoice|PaymentSchedule|Order): Promise => { setSubmitState(false); GTM.trackPurchase(result.id, result.total); afterSuccess(result); @@ -213,6 +225,7 @@ export const AbstractPaymentModal: React.FC = ({ isOp className={`gateway-form ${formClassName || ''}`} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={schedule}> diff --git a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx index 971f39278..b05240199 100644 --- a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx @@ -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 = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer }) => { +const CardPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, customer, order }) => { const { t } = useTranslation('shared'); const [gateway, setGateway] = useState(null); @@ -49,6 +51,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -63,6 +66,7 @@ const CardPaymentModal: React.FC = ({ isOpen, toggleModal afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={currentUser} schedule={schedule} customer={customer} />; @@ -99,4 +103,4 @@ const CardPaymentModalWrapper: React.FC = (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'])); diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index c7878043f..9119a8b9d 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -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 = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => { +export const LocalPaymentForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId, order }) => { const { t } = useTranslation('admin'); const [method, setMethod] = useState('check'); @@ -86,8 +87,13 @@ export const LocalPaymentForm: React.FC = ({ 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); } diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx index 9d0ee2032..ad04b105b 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-modal.tsx @@ -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 = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => { +const LocalPaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, order }) => { const { t } = useTranslation('admin'); /** @@ -54,7 +56,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod /** * Integrates the LocalPaymentForm into the parent AbstractPaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children, order }) => { return ( = ({ isOpen, toggleMod className={className} formId={formId} cart={cart} + order={order} updateCart={updateCart} customer={customer} paymentSchedule={paymentSchedule}> @@ -81,6 +84,7 @@ const LocalPaymentModal: React.FC = ({ isOpen, toggleMod formClassName="local-payment-form" currentUser={currentUser} cart={cart} + order={order} updateCart={updateCart} customer={customer} afterSuccess={afterSuccess} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index 55278d051..fb53a57e7 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -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 = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId }) => { +export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, updateCard = false, cart, customer, formId, order }) => { const PayZenKR = useRef(null); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); @@ -44,7 +46,7 @@ export const PayzenForm: React.FC = ({ 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 = ({ 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 = ({ 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 => { + const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise => { 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 = ({ 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(); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx index 0b6a70bf2..ea117b12f 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx @@ -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 which can handle the configuration * of a different payment gateway. */ -export const PayzenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const PayzenModal: React.FC = ({ 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 = ({ isOpen, toggleModal, a /** * Integrates the PayzenForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ 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 = ({ isOpen, toggleModal, a className="payzen-modal" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx index 0acd1b28d..f21c7c9b7 100644 --- a/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/payment-modal.tsx @@ -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 = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer }) => { +export const PaymentModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, operator, schedule, customer, order }) => { // the user's wallet const [wallet, setWallet] = useState(null); // the price of the cart @@ -44,10 +46,14 @@ export const PaymentModal: React.FC = ({ 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 = ({ 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 = ({ isOpen, toggleModal, afterSuccess={afterSuccess} onError={onError} cart={cart} + order={order} currentUser={operator} customer={customer} schedule={schedule} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index 0343e4bb7..3ea6fda3b 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -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 = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => { +export const StripeForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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); } diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx index f4974615f..69fcb46cf 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx @@ -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 which can handle the configuration * of a different payment gateway. */ -export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => { +export const StripeModal: React.FC = ({ 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 = ({ isOpen, toggleModal, a /** * Integrates the StripeForm into the parent PaymentModal */ - const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => { + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children, order }) => { return ( = ({ 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 = ({ isOpen, toggleModal, a formClassName="stripe-form" currentUser={currentUser} cart={cart} + order={order} customer={customer} afterSuccess={afterSuccess} onError={onError} diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 5cf4a7e30..cce5e4726 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -24,7 +24,7 @@ interface StoreProps { const Store: React.FC = ({ onError, currentUser }) => { const { t } = useTranslation('public'); - const { cart, setCart, reloadCart } = useCart(); + const { cart, setCart } = useCart(currentUser); const [products, setProducts] = useState>([]); @@ -40,12 +40,6 @@ const Store: React.FC = ({ onError, currentUser }) => { emitCustomEvent('CartUpdate', cart); }, [cart]); - useEffect(() => { - if (currentUser) { - reloadCart(); - } - }, [currentUser]); - return (
diff --git a/app/frontend/src/javascript/components/user/member-select.tsx b/app/frontend/src/javascript/components/user/member-select.tsx new file mode 100644 index 000000000..0685c1147 --- /dev/null +++ b/app/frontend/src/javascript/components/user/member-select.tsx @@ -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 = ({ defaultUser, onSelected }) => { + const { t } = useTranslation('public'); + const [value, setValue] = useState(); + + useEffect(() => { + if (defaultUser) { + setValue({ value: defaultUser.id, label: defaultUser.name }); + } + }, []); + + /** + * search members by name + */ + const loadMembers = async (inputValue: string): Promise> => { + 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 ( +
+
+

{t('app.public.member_select.select_a_member')}

+
+ +
+ ); +}; diff --git a/app/frontend/src/javascript/hooks/use-cart.ts b/app/frontend/src/javascript/hooks/use-cart.ts index bd7d8382f..a1617b667 100644 --- a/app/frontend/src/javascript/hooks/use-cart.ts +++ b/app/frontend/src/javascript/hooks/use-cart.ts @@ -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(null); const [loading, setLoading] = useState(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 }; } diff --git a/app/frontend/src/javascript/models/coupon.ts b/app/frontend/src/javascript/models/coupon.ts new file mode 100644 index 000000000..ad3ee624a --- /dev/null +++ b/app/frontend/src/javascript/models/coupon.ts @@ -0,0 +1,8 @@ +export interface Coupon { + id: number, + code: string, + type: string, + amount_off: number, + percent_off: number, + validity_per_user: string +} diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index f36393777..80d6d239e 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -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 +} diff --git a/app/models/order.rb b/app/models/order.rb index 2d848f325..8ea21a646 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -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 diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb index 0aad87849..401622563 100644 --- a/app/policies/cart_policy.rb +++ b/app/policies/cart_policy.rb @@ -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 diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb index 4f09104c0..298aafbc8 100644 --- a/app/services/cart/add_item_service.rb +++ b/app/services/cart/add_item_service.rb @@ -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 diff --git a/app/services/cart/create_service.rb b/app/services/cart/create_service.rb index 885bc00ec..996baa73d 100644 --- a/app/services/cart/create_service.rb +++ b/app/services/cart/create_service.rb @@ -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 diff --git a/app/services/cart/remove_item_service.rb b/app/services/cart/remove_item_service.rb index 8d7806aa8..cfa43c1ef 100644 --- a/app/services/cart/remove_item_service.rb +++ b/app/services/cart/remove_item_service.rb @@ -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 diff --git a/app/services/cart/set_customer_service.rb b/app/services/cart/set_customer_service.rb new file mode 100644 index 000000000..2cdc21e15 --- /dev/null +++ b/app/services/cart/set_customer_service.rb @@ -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 diff --git a/app/services/cart/set_quantity_service.rb b/app/services/cart/set_quantity_service.rb index 9d0095c34..396716de5 100644 --- a/app/services/cart/set_quantity_service.rb +++ b/app/services/cart/set_quantity_service.rb @@ -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 diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb new file mode 100644 index 000000000..ccd021df0 --- /dev/null +++ b/app/services/checkout/payment_service.rb @@ -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 diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb new file mode 100644 index 000000000..b4febade9 --- /dev/null +++ b/app/services/orders/order_service.rb @@ -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 diff --git a/app/services/payments/local_service.rb b/app/services/payments/local_service.rb new file mode 100644 index 000000000..d7912b0af --- /dev/null +++ b/app/services/payments/local_service.rb @@ -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 diff --git a/app/services/payments/payment_concern.rb b/app/services/payments/payment_concern.rb new file mode 100644 index 000000000..9b2a18554 --- /dev/null +++ b/app/services/payments/payment_concern.rb @@ -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 diff --git a/app/services/payments/payzen_service.rb b/app/services/payments/payzen_service.rb new file mode 100644 index 000000000..174d328d1 --- /dev/null +++ b/app/services/payments/payzen_service.rb @@ -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 diff --git a/app/services/payments/stripe_service.rb b/app/services/payments/stripe_service.rb new file mode 100644 index 000000000..de1e8773b --- /dev/null +++ b/app/services/payments/stripe_service.rb @@ -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 diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 1bfcdde75..d930be4b9 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -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 diff --git a/app/services/wallet_service.rb b/app/services/wallet_service.rb index 73f2b31ee..2d23ad77a 100644 --- a/app/services/wallet_service.rb +++ b/app/services/wallet_service.rb @@ -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 diff --git a/app/views/api/orders/_order.json.jbuilder b/app/views/api/orders/_order.json.jbuilder index c5883e467..a002b1483 100644 --- a/app/views/api/orders/_order.json.jbuilder +++ b/app/views/api/orders/_order.json.jbuilder @@ -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 diff --git a/config/locales/app.public.en.yml b/config/locales/app.public.en.yml index ac9bf7ab1..d2a3117ad 100644 --- a/config/locales/app.public.en.yml +++ b/config/locales/app.public.en.yml @@ -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" diff --git a/config/locales/app.public.fr.yml b/config/locales/app.public.fr.yml index e971c8e5f..0df4d3e45 100644 --- a/config/locales/app.public.fr.yml +++ b/config/locales/app.public.fr.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index ef60a1515..6a190075f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20220822081222_add_payment_state_to_order.rb b/db/migrate/20220822081222_add_payment_state_to_order.rb new file mode 100644 index 000000000..f803a40ee --- /dev/null +++ b/db/migrate/20220822081222_add_payment_state_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentStateToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_state, :string + end +end diff --git a/db/migrate/20220826074619_rename_amount_to_total_in_order.rb b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb new file mode 100644 index 000000000..bf583b032 --- /dev/null +++ b/db/migrate/20220826074619_rename_amount_to_total_in_order.rb @@ -0,0 +1,5 @@ +class RenameAmountToTotalInOrder < ActiveRecord::Migration[5.2] + def change + rename_column :orders, :amount, :total + end +end diff --git a/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb new file mode 100644 index 000000000..57eb279f5 --- /dev/null +++ b/db/migrate/20220826085923_add_order_item_id_to_product_stock_movement.rb @@ -0,0 +1,5 @@ +class AddOrderItemIdToProductStockMovement < ActiveRecord::Migration[5.2] + def change + add_column :product_stock_movements, :order_item_id, :integer + end +end diff --git a/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb new file mode 100644 index 000000000..d07d7bd14 --- /dev/null +++ b/db/migrate/20220826090821_add_wallet_amount_and_wallet_transaction_id_to_order.rb @@ -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 diff --git a/db/migrate/20220826091819_add_payment_method_to_order.rb b/db/migrate/20220826091819_add_payment_method_to_order.rb new file mode 100644 index 000000000..3be3c2740 --- /dev/null +++ b/db/migrate/20220826091819_add_payment_method_to_order.rb @@ -0,0 +1,5 @@ +class AddPaymentMethodToOrder < ActiveRecord::Migration[5.2] + def change + add_column :orders, :payment_method, :string + end +end diff --git a/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb new file mode 100644 index 000000000..c199208f0 --- /dev/null +++ b/db/migrate/20220826093503_rename_operator_id_to_operator_profile_id_in_order.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1025fe2e8..756cdbdb1 100644 --- a/db/schema.rb +++ b/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" diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index ea98c972b..05a377ca2 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -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| {