1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-29 10:24:20 +01:00

WIP: refactoring to singularize the booking process

We need to achieve only one process for all booking, not one for subscription, one for reservations, etc.
Moreover we must store one object per invoice_item/payment_schedule_object and stop using Invoice.invoiced or PaymentSchedule.scheduled
This commit is contained in:
Sylvain 2021-05-21 18:25:18 +02:00
parent 2fc0ad1ba0
commit c7a59c8cb7
40 changed files with 413 additions and 359 deletions

View File

@ -9,9 +9,9 @@ class API::LocalPaymentController < API::PaymentsController
authorize LocalPaymentContext.new(cart, price[:amount]) authorize LocalPaymentContext.new(cart, price[:amount])
if cart.reservation if cart.reservation
res = on_reservation_success(nil, nil, price[:details], cart) res = on_reservation_success(nil, nil, cart)
elsif cart.subscription elsif cart.subscription
res = on_subscription_success(nil, nil, price[:details], cart) res = on_subscription_success(nil, nil, cart)
end end
render res render res

View File

@ -12,6 +12,8 @@ class API::PaymentsController < API::ApiController
protected protected
def post_save(_gateway_item_id, _gateway_item_type); end
def post_reservation_save(_gateway_item_id, _gateway_item_type); end def post_reservation_save(_gateway_item_id, _gateway_item_type); end
def post_subscription_save(_gateway_item_id, _gateway_item_type); end def post_subscription_save(_gateway_item_id, _gateway_item_type); end
@ -50,49 +52,23 @@ class API::PaymentsController < API::ApiController
raise InvalidGroupError if plan.group_id != current_user.group_id raise InvalidGroupError if plan.group_id != current_user.group_id
end end
def on_reservation_success(gateway_item_id, gateway_item_type, details, cart) def on_success(gateway_item_id, gateway_item_type, cart)
@reservation = cart.reservation.to_reservation cart.pay_and_save(gateway_item_id, gateway_item_type)
@reservation.plan_id = cart.subscription.plan.id if cart.subscription end
payment_method = cart.payment_method || 'card' def on_reservation_success(gateway_item_id, gateway_item_type, cart)
user_id = if current_user.admin? || current_user.manager? is_reserve = on_success(gateway_item_id, gateway_item_type, cart)
cart.customer.id
else
current_user.id
end
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation,
payment_details: details,
payment_id: gateway_item_id,
payment_type: gateway_item_type,
schedule: cart.payment_schedule.requested,
payment_method: payment_method)
post_reservation_save(gateway_item_id, gateway_item_type) post_reservation_save(gateway_item_id, gateway_item_type)
if is_reserve if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
{ template: 'api/reservations/show', status: :created, location: @reservation } { template: 'api/reservations/show', status: :created, location: @reservation }
else else
{ json: @reservation.errors, status: :unprocessable_entity } { json: @reservation.errors, status: :unprocessable_entity }
end end
end end
def on_subscription_success(gateway_item_id, gateway_item_type, details, cart) def on_subscription_success(gateway_item_id, gateway_item_type, cart)
@subscription = cart.subscription.to_subscription is_subscribe = on_success(gateway_item_id, gateway_item_type, cart)
user_id = if current_user.admin? || current_user.manager?
cart.customer.id
else
current_user.id
end
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
.pay_and_save(@subscription,
payment_details: details,
payment_id: gateway_item_id,
payment_type: gateway_item_type,
schedule: cart.payment_schedule.requested,
payment_method: cart.payment_method || 'card')
post_subscription_save(gateway_item_id, gateway_item_type) post_subscription_save(gateway_item_id, gateway_item_type)
if is_subscribe if is_subscribe

View File

@ -48,13 +48,12 @@ class API::PayzenController < API::PaymentsController
order = client.get(params[:order_id], operation_type: 'DEBIT') order = client.get(params[:order_id], operation_type: 'DEBIT')
cart = shopping_cart cart = shopping_cart
amount = debit_amount(cart)
if order['answer']['transactions'].first['status'] == 'PAID' if order['answer']['transactions'].first['status'] == 'PAID'
if cart.reservation if cart.reservation
res = on_reservation_success(params[:order_id], amount[:details], cart) res = on_reservation_success(params[:order_id], cart)
elsif cart.subscription elsif cart.subscription
res = on_subscription_success(params[:order_id], amount[:details], cart) res = on_subscription_success(params[:order_id], cart)
end end
end end
@ -65,12 +64,12 @@ class API::PayzenController < API::PaymentsController
private private
def on_reservation_success(order_id, details, cart) def on_reservation_success(order_id, cart)
super(order_id, 'PayZen::Order', details, cart) super(order_id, 'PayZen::Order', cart)
end end
def on_subscription_success(order_id, details, cart) def on_subscription_success(order_id, cart)
super(order_id, 'PayZen::Order', details, cart) super(order_id, 'PayZen::Order', cart)
end end
def error_handling def error_handling

View File

@ -12,13 +12,12 @@ class API::StripeController < API::PaymentsController
def confirm_payment def confirm_payment
render(json: { error: 'Bad gateway or online payment is disabled' }, status: :bad_gateway) and return unless Stripe::Helper.enabled? render(json: { error: 'Bad gateway or online payment is disabled' }, status: :bad_gateway) and return unless Stripe::Helper.enabled?
amount = nil # will contains the amount and the details of each invoice lines
intent = nil # stripe's payment intent intent = nil # stripe's payment intent
res = nil # json of the API answer res = nil # json of the API answer
cart = shopping_cart cart = shopping_cart
begin begin
amount = debit_amount(cart) amount = debit_amount(cart) # will contains the amount and the details of each invoice lines
if params[:payment_method_id].present? if params[:payment_method_id].present?
check_coupon(cart) check_coupon(cart)
check_plan(cart) check_plan(cart)
@ -48,9 +47,9 @@ class API::StripeController < API::PaymentsController
if intent&.status == 'succeeded' if intent&.status == 'succeeded'
if cart.reservation if cart.reservation
res = on_reservation_success(intent, amount[:details], cart) res = on_reservation_success(intent, cart)
elsif cart.subscription elsif cart.subscription
res = on_subscription_success(intent, amount[:details], cart) res = on_subscription_success(intent, cart)
end end
end end
@ -81,12 +80,11 @@ class API::StripeController < API::PaymentsController
intent = Stripe::SetupIntent.retrieve(params[:setup_intent_id], api_key: key) intent = Stripe::SetupIntent.retrieve(params[:setup_intent_id], api_key: key)
cart = shopping_cart cart = shopping_cart
amount = debit_amount(cart)
if intent&.status == 'succeeded' if intent&.status == 'succeeded'
if cart.reservation if cart.reservation
res = on_reservation_success(intent, amount[:details], cart) res = on_reservation_success(intent, cart)
elsif cart.subscription elsif cart.subscription
res = on_subscription_success(intent, amount[:details], cart) res = on_subscription_success(intent, cart)
end end
end end
@ -128,12 +126,12 @@ class API::StripeController < API::PaymentsController
) )
end end
def on_reservation_success(intent, details, cart) def on_reservation_success(intent, cart)
super(intent.id, intent.class.name, details, cart) super(intent.id, intent.class.name, cart)
end end
def on_subscription_success(intent, details, cart) def on_subscription_success(intent, cart)
super(intent.id, intent.class.name, details, cart) super(intent.id, intent.class.name, cart)
end end
def generate_payment_response(intent, res = nil) def generate_payment_response(intent, res = nil)

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client'; import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { CartItems } from '../models/payment'; import { ShoppingCart } from '../models/payment';
import { User } from '../models/user'; import { User } from '../models/user';
import { import {
CheckHashResponse, CheckHashResponse,
@ -17,13 +17,13 @@ export default class PayzenAPI {
return res?.data; return res?.data;
} }
static async chargeCreatePayment(cartItems: CartItems, customer: User): Promise<CreatePaymentResponse> { static async chargeCreatePayment(cart: ShoppingCart, customer: User): Promise<CreatePaymentResponse> {
const res: AxiosResponse<CreatePaymentResponse> = await apiClient.post('/api/payzen/create_payment', { cart_items: cartItems, customer_id: customer.id }); const res: AxiosResponse<CreatePaymentResponse> = await apiClient.post('/api/payzen/create_payment', { cart_items: cart, customer_id: customer.id });
return res?.data; return res?.data;
} }
static async chargeCreateToken(cartItems: CartItems, customer: User): Promise<CreateTokenResponse> { static async chargeCreateToken(cart: ShoppingCart, customer: User): Promise<CreateTokenResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/create_token', { cart_items: cartItems, customer_id: customer.id }); const res: AxiosResponse = await apiClient.post('/api/payzen/create_token', { cart_items: cart, customer_id: customer.id });
return res?.data; return res?.data;
} }
@ -32,8 +32,8 @@ export default class PayzenAPI {
return res?.data; return res?.data;
} }
static async confirm(orderId: string, cartItems: CartItems): Promise<ConfirmPaymentResponse> { static async confirm(orderId: string, cart: ShoppingCart): Promise<ConfirmPaymentResponse> {
const res: AxiosResponse<ConfirmPaymentResponse> = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId }); const res: AxiosResponse<ConfirmPaymentResponse> = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cart, order_id: orderId });
return res?.data; return res?.data;
} }
} }

View File

@ -1,11 +1,11 @@
import apiClient from './clients/api-client'; import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { CartItems } from '../models/payment'; import { ShoppingCart } from '../models/payment';
import { ComputePriceResult } from '../models/price'; import { ComputePriceResult } from '../models/price';
export default class PriceAPI { export default class PriceAPI {
static async compute (cartItems: CartItems): Promise<ComputePriceResult> { static async compute (cart: ShoppingCart): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cartItems); const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart);
return res?.data; return res?.data;
} }
} }

View File

@ -1,9 +1,9 @@
import apiClient from './clients/api-client'; import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { CartItems, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment'; import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
export default class StripeAPI { export default class StripeAPI {
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> { static async confirm (stp_payment_method_id: string, cart_items: ShoppingCart): Promise<PaymentConfirmation> {
const res: AxiosResponse = await apiClient.post(`/api/stripe/confirm_payment`, { const res: AxiosResponse = await apiClient.post(`/api/stripe/confirm_payment`, {
payment_method_id: stp_payment_method_id, payment_method_id: stp_payment_method_id,
cart_items cart_items
@ -17,7 +17,7 @@ export default class StripeAPI {
} }
// TODO, type the response // TODO, type the response
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise<any> { static async confirmPaymentSchedule (setup_intent_id: string, cart_items: ShoppingCart): Promise<any> {
const res: AxiosResponse = await apiClient.post(`/api/stripe/confirm_payment_schedule`, { const res: AxiosResponse = await apiClient.post(`/api/stripe/confirm_payment_schedule`, {
setup_intent_id, setup_intent_id,
cart_items cart_items

View File

@ -6,7 +6,7 @@ import { FabModal, ModalSize } from '../base/fab-modal';
import { HtmlTranslate } from '../base/html-translate'; import { HtmlTranslate } from '../base/html-translate';
import { CustomAssetName } from '../../models/custom-asset'; import { CustomAssetName } from '../../models/custom-asset';
import { IFablab } from '../../models/fablab'; import { IFablab } from '../../models/fablab';
import { CartItems } from '../../models/payment'; import { ShoppingCart } from '../../models/payment';
import { PaymentSchedule } from '../../models/payment-schedule'; import { PaymentSchedule } from '../../models/payment-schedule';
import { User } from '../../models/user'; import { User } from '../../models/user';
import CustomAssetAPI from '../../api/custom-asset'; import CustomAssetAPI from '../../api/custom-asset';
@ -24,7 +24,7 @@ export interface GatewayFormProps {
operator: User, operator: User,
className?: string, className?: string,
paymentSchedule?: boolean, paymentSchedule?: boolean,
cartItems?: CartItems, cart?: ShoppingCart,
formId: string, formId: string,
} }
@ -32,7 +32,7 @@ interface AbstractPaymentModalProps {
isOpen: boolean, isOpen: boolean,
toggleModal: () => void, toggleModal: () => void,
afterSuccess: (result: any) => void, afterSuccess: (result: any) => void,
cartItems: CartItems, cart: ShoppingCart,
currentUser: User, currentUser: User,
schedule: PaymentSchedule, schedule: PaymentSchedule,
customer: User, customer: User,
@ -53,7 +53,7 @@ const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
* This component must not be called directly but must be extended for each implemented payment gateway * 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 * @see https://reactjs.org/docs/composition-vs-inheritance.html
*/ */
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => { export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => {
// customer's wallet // customer's wallet
const [wallet, setWallet] = useState(null); const [wallet, setWallet] = useState(null);
// server-computed price with all details // server-computed price with all details
@ -80,17 +80,17 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* - Refresh the remaining price * - Refresh the remaining price
*/ */
useEffect(() => { useEffect(() => {
if (!cartItems) return; if (!cart) return;
WalletAPI.getByUser(cartItems.customer_id).then((wallet) => { WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet); setWallet(wallet);
PriceAPI.compute(cartItems).then((res) => { PriceAPI.compute(cart).then((res) => {
setPrice(res); setPrice(res);
const wLib = new WalletLib(wallet); const wLib = new WalletLib(wallet);
setRemainingPrice(wLib.computeRemainingPrice(res.price)); setRemainingPrice(wLib.computeRemainingPrice(res.price));
setReady(true); setReady(true);
}) })
}) })
}, [cartItems]); }, [cart]);
/** /**
* Check if there is currently an error to display * Check if there is currently an error to display
@ -170,14 +170,14 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
customFooter={logoFooter} customFooter={logoFooter}
className={`payment-modal ${className ? className : ''}`}> className={`payment-modal ${className ? className : ''}`}>
{ready && <div> {ready && <div>
<WalletInfo cartItems={cartItems} currentUser={currentUser} wallet={wallet} price={price?.price} /> <WalletInfo cart={cart} currentUser={currentUser} wallet={wallet} price={price?.price} />
<GatewayForm onSubmit={handleSubmit} <GatewayForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess} onSuccess={handleFormSuccess}
onError={handleFormError} onError={handleFormError}
operator={currentUser} operator={currentUser}
className={`gateway-form ${formClassName ? formClassName : ''}`} className={`gateway-form ${formClassName ? formClassName : ''}`}
formId={formId} formId={formId}
cartItems={cartItems} cart={cart}
customer={customer} customer={customer}
paymentSchedule={isPaymentSchedule()}> paymentSchedule={isPaymentSchedule()}>
{hasErrors() && <div className="payment-errors"> {hasErrors() && <div className="payment-errors">

View File

@ -1,6 +1,6 @@
import React, { ReactElement, ReactNode } from 'react'; import React, { ReactElement, ReactNode } from 'react';
import { IApplication } from '../../models/application'; import { IApplication } from '../../models/application';
import { CartItems } from '../../models/payment'; import { ShoppingCart } from '../../models/payment';
import { User } from '../../models/user'; import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule'; import { PaymentSchedule } from '../../models/payment-schedule';
import { Loader } from '../base/loader'; import { Loader } from '../base/loader';
@ -16,7 +16,7 @@ interface PaymentModalProps {
isOpen: boolean, isOpen: boolean,
toggleModal: () => void, toggleModal: () => void,
afterSuccess: (result: any) => void, afterSuccess: (result: any) => void,
cartItems: CartItems, cart: ShoppingCart,
currentUser: User, currentUser: User,
schedule: PaymentSchedule, schedule: PaymentSchedule,
customer: User customer: User
@ -29,7 +29,7 @@ const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data * This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment. * to process an online payment.
*/ */
const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, customer }) => { const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cart, customer }) => {
const gateway = paymentGateway.read(); const gateway = paymentGateway.read();
/** /**
@ -39,7 +39,7 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
return <StripeModal isOpen={isOpen} return <StripeModal isOpen={isOpen}
toggleModal={toggleModal} toggleModal={toggleModal}
afterSuccess={afterSuccess} afterSuccess={afterSuccess}
cartItems={cartItems} cart={cart}
currentUser={currentUser} currentUser={currentUser}
schedule={schedule} schedule={schedule}
customer={customer} /> customer={customer} />
@ -52,7 +52,7 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
return <PayZenModal isOpen={isOpen} return <PayZenModal isOpen={isOpen}
toggleModal={toggleModal} toggleModal={toggleModal}
afterSuccess={afterSuccess} afterSuccess={afterSuccess}
cartItems={cartItems} cart={cart}
currentUser={currentUser} currentUser={currentUser}
schedule={schedule} schedule={schedule}
customer={customer} /> customer={customer} />
@ -73,12 +73,12 @@ const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterS
} }
const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, customer }) => { const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cart, customer }) => {
return ( return (
<Loader> <Loader>
<PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cartItems={cartItems} customer={customer} /> <PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
</Loader> </Loader>
); );
} }
Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cartItems', 'customer'])); Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cart', 'customer']));

View File

@ -16,7 +16,7 @@ import {
* A form component to collect the credit card details and to create the payment method on Stripe. * 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}. * The form validation button must be created elsewhere, using the attribute form={formId}.
*/ */
export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator, formId }) => { export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => {
const PayZenKR = useRef<KryptonClient>(null); const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
@ -39,7 +39,7 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
.then(({ KR }) => PayZenKR.current = KR); .then(({ KR }) => PayZenKR.current = KR);
}).catch(error => onError(error)); }).catch(error => onError(error));
}); });
}, [cartItems, paymentSchedule, customer]); }, [cart, paymentSchedule, customer]);
/** /**
* Ask the API to create the form token. * Ask the API to create the form token.
@ -47,9 +47,9 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
*/ */
const createToken = async (): Promise<CreateTokenResponse> => { const createToken = async (): Promise<CreateTokenResponse> => {
if (paymentSchedule) { if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cartItems, customer); return await PayzenAPI.chargeCreateToken(cart, customer);
} else { } else {
return await PayzenAPI.chargeCreatePayment(cartItems, customer); return await PayzenAPI.chargeCreatePayment(cart, customer);
} }
} }
@ -63,7 +63,7 @@ export const PayzenForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
const transaction = event.clientAnswer.transactions[0]; const transaction = event.clientAnswer.transactions[0];
if (event.clientAnswer.orderStatus === 'PAID') { if (event.clientAnswer.orderStatus === 'PAID') {
PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cartItems).then((confirmation) => { PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart).then((confirmation) => {
PayZenKR.current.removeForms().then(() => { PayZenKR.current.removeForms().then(() => {
onSuccess(confirmation); onSuccess(confirmation);
}); });

View File

@ -1,6 +1,6 @@
import React, { FunctionComponent, ReactNode } from 'react'; import React, { FunctionComponent, ReactNode } from 'react';
import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal'; import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal';
import { CartItems, PaymentConfirmation } from '../../../models/payment'; import { ShoppingCart, PaymentConfirmation } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule'; import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user'; import { User } from '../../../models/user';
@ -14,7 +14,7 @@ interface PayZenModalProps {
isOpen: boolean, isOpen: boolean,
toggleModal: () => void, toggleModal: () => void,
afterSuccess: (result: PaymentConfirmation) => void, afterSuccess: (result: PaymentConfirmation) => void,
cartItems: CartItems, cart: ShoppingCart,
currentUser: User, currentUser: User,
schedule: PaymentSchedule, schedule: PaymentSchedule,
customer: User customer: User
@ -27,7 +27,7 @@ interface PayZenModalProps {
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration * This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway. * of a different payment gateway.
*/ */
export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer }) => { export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
/** /**
* Return the logos, shown in the modal footer. * Return the logos, shown in the modal footer.
*/ */
@ -44,7 +44,7 @@ export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, a
/** /**
* Integrates the PayzenForm into the parent PaymentModal * Integrates the PayzenForm into the parent PaymentModal
*/ */
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cartItems, customer, paymentSchedule, children}) => { const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
return ( return (
<PayzenForm onSubmit={onSubmit} <PayzenForm onSubmit={onSubmit}
onSuccess={onSuccess} onSuccess={onSuccess}
@ -52,7 +52,7 @@ export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, a
customer={customer} customer={customer}
operator={operator} operator={operator}
formId={formId} formId={formId}
cartItems={cartItems} cart={cart}
className={className} className={className}
paymentSchedule={paymentSchedule}> paymentSchedule={paymentSchedule}>
{children} {children}
@ -68,7 +68,7 @@ export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, a
formClassName="payzen-form" formClassName="payzen-form"
className="payzen-modal" className="payzen-modal"
currentUser={currentUser} currentUser={currentUser}
cartItems={cartItems} cart={cart}
customer={customer} customer={customer}
afterSuccess={afterSuccess} afterSuccess={afterSuccess}
schedule={schedule} schedule={schedule}

View File

@ -14,7 +14,7 @@ interface StripeFormProps extends GatewayFormProps {
* A form component to collect the credit card details and to create the payment method on Stripe. * 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}. * The form validation button must be created elsewhere, using the attribute form={formId}.
*/ */
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator, formId }) => { export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => {
const { t } = useTranslation('shared'); const { t } = useTranslation('shared');
@ -45,7 +45,7 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
try { try {
if (!paymentSchedule) { if (!paymentSchedule) {
// process the normal payment pipeline, including SCA validation // process the normal payment pipeline, including SCA validation
const res = await StripeAPI.confirm(paymentMethod.id, cartItems); const res = await StripeAPI.confirm(paymentMethod.id, cart);
await handleServerConfirmation(res); await handleServerConfirmation(res);
} else { } else {
// we start by associating the payment method with the user // we start by associating the payment method with the user
@ -66,7 +66,7 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
onError(error.message); onError(error.message);
} else { } else {
// then we confirm the payment schedule // then we confirm the payment schedule
const res = await StripeAPI.confirmPaymentSchedule(setupIntent.id, cartItems); const res = await StripeAPI.confirmPaymentSchedule(setupIntent.id, cart);
onSuccess(res); onSuccess(res);
} }
} }
@ -100,7 +100,7 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
// The card action has been handled // The card action has been handled
// The PaymentIntent can be confirmed again on the server // The PaymentIntent can be confirmed again on the server
try { try {
const confirmation = await StripeAPI.confirm(result.paymentIntent.id, cartItems); const confirmation = await StripeAPI.confirm(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation); await handleServerConfirmation(confirmation);
} catch (e) { } catch (e) {
onError(e); onError(e);

View File

@ -3,7 +3,7 @@ import { SetupIntent } from '@stripe/stripe-js';
import { StripeElements } from './stripe-elements'; import { StripeElements } from './stripe-elements';
import { StripeForm } from './stripe-form'; import { StripeForm } from './stripe-form';
import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal'; import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal';
import { CartItems, PaymentConfirmation } from '../../../models/payment'; import { ShoppingCart, PaymentConfirmation } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule'; import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user'; import { User } from '../../../models/user';
@ -16,7 +16,7 @@ interface StripeModalProps {
isOpen: boolean, isOpen: boolean,
toggleModal: () => void, toggleModal: () => void,
afterSuccess: (result: SetupIntent|PaymentConfirmation) => void, afterSuccess: (result: SetupIntent|PaymentConfirmation) => void,
cartItems: CartItems, cart: ShoppingCart,
currentUser: User, currentUser: User,
schedule: PaymentSchedule, schedule: PaymentSchedule,
customer: User customer: User
@ -29,7 +29,7 @@ interface StripeModalProps {
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration * This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway. * of a different payment gateway.
*/ */
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer }) => { export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
/** /**
* Return the logos, shown in the modal footer. * Return the logos, shown in the modal footer.
*/ */
@ -47,7 +47,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
/** /**
* Integrates the StripeForm into the parent PaymentModal * Integrates the StripeForm into the parent PaymentModal
*/ */
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cartItems, customer, paymentSchedule, children}) => { const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
return ( return (
<StripeElements> <StripeElements>
<StripeForm onSubmit={onSubmit} <StripeForm onSubmit={onSubmit}
@ -56,7 +56,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
operator={operator} operator={operator}
className={className} className={className}
formId={formId} formId={formId}
cartItems={cartItems} cart={cart}
customer={customer} customer={customer}
paymentSchedule={paymentSchedule}> paymentSchedule={paymentSchedule}>
{children} {children}
@ -73,7 +73,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
formId="stripe-form" formId="stripe-form"
formClassName="stripe-form" formClassName="stripe-form"
currentUser={currentUser} currentUser={currentUser}
cartItems={cartItems} cart={cart}
customer={customer} customer={customer}
afterSuccess={afterSuccess} afterSuccess={afterSuccess}
schedule={schedule} schedule={schedule}

View File

@ -8,7 +8,7 @@ import { User } from '../models/user';
import { Wallet } from '../models/wallet'; import { Wallet } from '../models/wallet';
import { IFablab } from '../models/fablab'; import { IFablab } from '../models/fablab';
import WalletLib from '../lib/wallet'; import WalletLib from '../lib/wallet';
import { CartItems } from '../models/payment'; import { ShoppingCart } from '../models/payment';
import { Reservation } from '../models/reservation'; import { Reservation } from '../models/reservation';
import { SubscriptionRequest } from '../models/subscription'; import { SubscriptionRequest } from '../models/subscription';
@ -16,7 +16,7 @@ declare var Application: IApplication;
declare var Fablab: IFablab; declare var Fablab: IFablab;
interface WalletInfoProps { interface WalletInfoProps {
cartItems: CartItems, cart: ShoppingCart,
currentUser: User, currentUser: User,
wallet: Wallet, wallet: Wallet,
price: number, price: number,
@ -25,7 +25,7 @@ interface WalletInfoProps {
/** /**
* This component displays a summary of the amount paid with the virtual wallet, for the current transaction * This component displays a summary of the amount paid with the virtual wallet, for the current transaction
*/ */
export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser, wallet, price }) => { export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, wallet, price }) => {
const { t } = useTranslation('shared'); const { t } = useTranslation('shared');
const [remainingPrice, setRemainingPrice] = useState(0); const [remainingPrice, setRemainingPrice] = useState(0);
@ -48,7 +48,7 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
* If the currently connected user (i.e. the operator), is an admin or a manager, he may book the reservation for someone else. * If the currently connected user (i.e. the operator), is an admin or a manager, he may book the reservation for someone else.
*/ */
const isOperatorAndClient = (): boolean => { const isOperatorAndClient = (): boolean => {
return currentUser.id == cartItems.customer_id; return currentUser.id == cart.customer_id;
} }
/** /**
* If the client has some money in his wallet & the price is not zero, then we should display this component. * If the client has some money in his wallet & the price is not zero, then we should display this component.
@ -67,17 +67,17 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
* Does the current cart contains a payment schedule? * Does the current cart contains a payment schedule?
*/ */
const isPaymentSchedule = (): boolean => { const isPaymentSchedule = (): boolean => {
return cartItems.subscription && cartItems.payment_schedule; return cart.items.find(i => 'subscription' in i) && cart.payment_schedule;
} }
/** /**
* Return the human-readable name of the item currently bought with the wallet * Return the human-readable name of the item currently bought with the wallet
*/ */
const getPriceItem = (): string => { const getPriceItem = (): string => {
let item = 'other'; let item = 'other';
if (cartItems.reservation) { if (cart.items.find(i => 'reservation' in i)) {
item = 'reservation'; item = 'reservation';
} else if (cartItems.subscription) { } else if (cart.items.find(i => 'subscription' in i)) {
if (cartItems.payment_schedule) { if (cart.payment_schedule) {
item = 'first_deadline'; item = 'first_deadline';
} else item = 'subscription'; } else item = 'subscription';
} }
@ -121,12 +121,12 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
); );
} }
const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, cartItems, price, wallet }) => { const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, cart, price, wallet }) => {
return ( return (
<Loader> <Loader>
<WalletInfo currentUser={currentUser} cartItems={cartItems} price={price} wallet={wallet}/> <WalletInfo currentUser={currentUser} cart={cart} price={price} wallet={wallet}/>
</Loader> </Loader>
); );
} }
Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cartItems', 'wallet'])); Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cart', 'wallet']));

View File

@ -335,16 +335,20 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.validReserveEvent = function () { $scope.validReserveEvent = function () {
const cartItems = { const cartItems = {
customer_id: $scope.ctrl.member.id, customer_id: $scope.ctrl.member.id,
reservation: { items: [
reservable_id: $scope.event.id, {
reservable_type: 'Event', reservation: {
slots_attributes: [], reservable_id: $scope.event.id,
nb_reserve_places: $scope.reserve.nbReservePlaces, reservable_type: 'Event',
tickets_attributes: [] slots_attributes: [],
} nb_reserve_places: $scope.reserve.nbReservePlaces,
tickets_attributes: []
}
}
]
} }
// a single slot is used for events // a single slot is used for events
cartItems.reservation.slots_attributes.push({ cartItems.items[0].reservation.slots_attributes.push({
start_at: $scope.event.start_date, start_at: $scope.event.start_date,
end_at: $scope.event.end_date, end_at: $scope.event.end_date,
availability_id: $scope.event.availability.id availability_id: $scope.event.availability.id
@ -353,7 +357,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
for (let price_id in $scope.reserve.tickets) { for (let price_id in $scope.reserve.tickets) {
if (Object.prototype.hasOwnProperty.call($scope.reserve.tickets, price_id)) { if (Object.prototype.hasOwnProperty.call($scope.reserve.tickets, price_id)) {
const seats = $scope.reserve.tickets[price_id]; const seats = $scope.reserve.tickets[price_id];
cartItems.reservation.tickets_attributes.push({ cartItems.items[0].reservation.tickets_attributes.push({
event_price_category_id: price_id, event_price_category_id: price_id,
booked: seats booked: seats
}); });
@ -363,7 +367,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.attempting = true; $scope.attempting = true;
// save the reservation to the API // save the reservation to the API
return Reservation.save(cartItems, function (reservation) { return Reservation.save(cartItems, function (reservation) {
// reservation successfull // reservation successful
afterPayment(reservation); afterPayment(reservation);
return $scope.attempting = false; return $scope.attempting = false;
} }
@ -657,7 +661,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* @param reservation {Object} as returned by mkReservation() * @param reservation {Object} as returned by mkReservation()
* @param coupon {Object} Coupon as returned from the API * @param coupon {Object} Coupon as returned from the API
* @param paymentMethod {string} 'card' | '' * @param paymentMethod {string} 'card' | ''
* @return {CartItems} * @return {ShoppingCart}
*/ */
const mkCartItems = function (reservation, coupon, paymentMethod = '') { const mkCartItems = function (reservation, coupon, paymentMethod = '') {
return { return {

View File

@ -681,10 +681,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
}; };
/** /**
* Build the CartItems object, from the current reservation * Build the ShoppingCart object, from the current reservation
* @param items {Array<{reservation:{reservable_type: string, reservable_id: string, slots_attributes: []}}|{subscription: {plan_id: number}}>} * @param items {Array<{reservation:{reservable_type: string, reservable_id: string, slots_attributes: []}}|{subscription: {plan_id: number}}>}
* @param paymentMethod {string} * @param paymentMethod {string}
* @return {CartItems} * @return {ShoppingCart}
*/ */
const mkCartItems = function (items, paymentMethod = '') { const mkCartItems = function (items, paymentMethod = '') {
return { return {

View File

@ -19,9 +19,11 @@ export enum PaymentMethod {
Other = '' Other = ''
} }
export interface CartItems { export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest };
export interface ShoppingCart {
customer_id: number, customer_id: number,
items: Array<Reservation|SubscriptionRequest>, items: Array<CartItem>,
coupon_code?: string, coupon_code?: string,
payment_schedule?: boolean, payment_schedule?: boolean,
payment_method: PaymentMethod payment_method: PaymentMethod

View File

@ -209,7 +209,7 @@
<payment-modal is-open="onlinePayment.showModal" <payment-modal is-open="onlinePayment.showModal"
toggle-modal="toggleOnlinePaymentModal" toggle-modal="toggleOnlinePaymentModal"
after-success="afterOnlinePaymentSuccess" after-success="afterOnlinePaymentSuccess"
cart-items="onlinePayment.cartItems" cart="onlinePayment.cartItems"
current-user="currentUser" current-user="currentUser"
customer="ctrl.member"/> customer="ctrl.member"/>
</div> </div>

View File

@ -203,7 +203,7 @@
<payment-modal is-open="onlinePayment.showModal" <payment-modal is-open="onlinePayment.showModal"
toggle-modal="toggleOnlinePaymentModal" toggle-modal="toggleOnlinePaymentModal"
after-success="afterOnlinePaymentSuccess" after-success="afterOnlinePaymentSuccess"
cart-items="onlinePayment.cartItems" cart="onlinePayment.cartItems"
current-user="currentUser" current-user="currentUser"
customer="user" customer="user"
schedule="schedule.payment_schedule"/> schedule="schedule.payment_schedule"/>

View File

@ -41,7 +41,7 @@
</div> </div>
<div class="row"> <div class="row">
<wallet-info current-user="currentUser" <wallet-info current-user="currentUser"
cart-items="cartItems" cart="cartItems"
price="price" price="price"
wallet="wallet"/> wallet="wallet"/>
</div> </div>
@ -52,7 +52,7 @@
<payment-modal is-open="isOpenOnlinePaymentModal" <payment-modal is-open="isOpenOnlinePaymentModal"
toggle-modal="toggleOnlinePaymentModal" toggle-modal="toggleOnlinePaymentModal"
after-success="afterCreatePaymentSchedule" after-success="afterCreatePaymentSchedule"
cart-items="cartItems" cart="cartItems"
current-user="currentUser" current-user="currentUser"
schedule="schedule" schedule="schedule"
customer="user" customer="user"

View File

@ -12,4 +12,6 @@ class CartItem::BaseItem
def name def name
'' ''
end end
def to_object; end
end end

View File

@ -34,13 +34,14 @@ class CartItem::EventReservation < CartItem::Reservation
{ elements: elements, amount: total } { elements: elements, amount: total }
end end
def to_reservation def to_object
::Reservation.new( ::Reservation.new(
reservable_id: @reservable.id, reservable_id: @reservable.id,
reservable_type: Event.name, reservable_type: Event.name,
slots_attributes: slots_params, slots_attributes: slots_params,
tickets_attributes: tickets_params, tickets_attributes: tickets_params,
nb_reserve_places: @normal_tickets nb_reserve_places: @normal_tickets,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
) )
end end

View File

@ -12,11 +12,12 @@ class CartItem::MachineReservation < CartItem::Reservation
@new_subscription = new_subscription @new_subscription = new_subscription
end end
def to_reservation def to_object
::Reservation.new( ::Reservation.new(
reservable_id: @reservable.id, reservable_id: @reservable.id,
reservable_type: Machine.name, reservable_type: Machine.name,
slots_attributes: slots_params slots_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
) )
end end

View File

@ -33,10 +33,6 @@ class CartItem::Reservation < CartItem::BaseItem
@reservable.name @reservable.name
end end
def to_reservation
nil
end
protected protected
def credits def credits

View File

@ -12,11 +12,12 @@ class CartItem::SpaceReservation < CartItem::Reservation
@new_subscription = new_subscription @new_subscription = new_subscription
end end
def to_reservation def to_object
::Reservation.new( ::Reservation.new(
reservable_id: @reservable.id, reservable_id: @reservable.id,
reservable_type: Space.name, reservable_type: Space.name,
slots_attributes: slots_params slots_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
) )
end end

View File

@ -4,10 +4,11 @@
class CartItem::Subscription < CartItem::BaseItem class CartItem::Subscription < CartItem::BaseItem
attr_reader :plan attr_reader :plan
def initialize(plan) def initialize(plan, customer)
raise TypeError unless plan.is_a? Plan raise TypeError unless plan.is_a? Plan
@plan = plan @plan = plan
@customer = customer
end end
def price def price
@ -21,9 +22,10 @@ class CartItem::Subscription < CartItem::BaseItem
@plan.name @plan.name
end end
def to_subscription def to_object
Subscription.new( Subscription.new(
plan_id: @plan.id plan_id: @plan.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
) )
end end
end end

View File

@ -32,11 +32,12 @@ class CartItem::TrainingReservation < CartItem::Reservation
{ elements: elements, amount: amount } { elements: elements, amount: amount }
end end
def to_reservation def to_object
::Reservation.new( ::Reservation.new(
reservable_id: @reservable.id, reservable_id: @reservable.id,
reservable_type: Training.name, reservable_type: Training.name,
slots_attributes: slots_params slots_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
) )
end end

View File

@ -23,24 +23,17 @@ class Reservation < ApplicationRecord
validates_presence_of :reservable_id, :reservable_type validates_presence_of :reservable_id, :reservable_type
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) } validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }
validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) } validate :training_not_fully_reserved, if: -> { reservable.is_a?(Training) }
validate :slots_not_locked
validates_with ReservationSlotSubscriptionValidator validates_with ReservationSlotSubscriptionValidator
attr_accessor :plan_id, :subscription attr_accessor :plan_id, :subscription
after_commit :notify_member_create_reservation, on: :create after_commit :notify_member_create_reservation, on: :create
after_commit :notify_admin_member_create_reservation, on: :create after_commit :notify_admin_member_create_reservation, on: :create
after_commit :update_credits, on: :create
after_commit :extend_subscription, on: :create
after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' } after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' }
##
# These checks will run before the invoice/payment-schedule is generated
##
def pre_check
# check that none of the reserved availabilities was locked
slots.each do |slot|
raise LockedError if slot.availability.lock
end
end
## Generate the subscription associated with for the current reservation ## Generate the subscription associated with for the current reservation
def generate_subscription def generate_subscription
return unless plan_id return unless plan_id
@ -50,13 +43,6 @@ class Reservation < ApplicationRecord
subscription subscription
end end
##
# These actions will be realized after the reservation is initially saved (on creation)
##
def post_save
UsersCredits::Manager.new(reservation: self).update_credits
end
# @param canceled if true, count the number of seats for this reservation, including canceled seats # @param canceled if true, count the number of seats for this reservation, including canceled seats
def total_booked_seats(canceled: false) def total_booked_seats(canceled: false)
# cases: # cases:
@ -106,6 +92,21 @@ class Reservation < ApplicationRecord
errors.add(:training, 'already fully reserved') if Availability.find(slot.availability_id).completed? errors.add(:training, 'already fully reserved') if Availability.find(slot.availability_id).completed?
end end
def slots_not_locked
# check that none of the reserved availabilities was locked
slots.each do |slot|
errors.add(:slots, 'locked') if slot.availability.lock
end
end
def update_credits
UsersCredits::Manager.new(reservation: self).update_credits
end
def extend_subscription
SubscriptionExtensionAfterReservation.new(self).extend_subscription_if_eligible
end
def notify_member_create_reservation def notify_member_create_reservation
NotificationCenter.call type: 'notify_member_create_reservation', NotificationCenter.call type: 'notify_member_create_reservation',
receiver: user, receiver: user,

View File

@ -2,16 +2,18 @@
# Stores data about a shopping data # Stores data about a shopping data
class ShoppingCart class ShoppingCart
attr_accessor :customer, :payment_method, :items, :coupon, :payment_schedule attr_accessor :customer, :operator, :payment_method, :items, :coupon, :payment_schedule
# @param items {Array<CartItem::BaseItem>} # @param items {Array<CartItem::BaseItem>}
# @param coupon {CartItem::Coupon} # @param coupon {CartItem::Coupon}
# @param payment_schedule {CartItem::PaymentSchedule} # @param payment_schedule {CartItem::PaymentSchedule}
# @param customer {User} # @param customer {User}
def initialize(customer, coupon, payment_schedule, payment_method = '', items: []) # @param operator {User}
def initialize(customer, operator, coupon, payment_schedule, payment_method = '', items: [])
raise TypeError unless customer.is_a? User raise TypeError unless customer.is_a? User
@customer = customer @customer = customer
@operator = operator
@payment_method = payment_method @payment_method = payment_method
@items = items @items = items
@coupon = coupon @coupon = coupon
@ -50,4 +52,44 @@ class ShoppingCart
schedule: schedule_info[:schedule] schedule: schedule_info[:schedule]
} }
end end
def pay_and_save(payment_id, payment_type)
price = total
objects = []
ActiveRecord::Base.transaction do
items.each do |item|
object = item.to_object
object.save
objects.push(object)
raise ActiveRecord::Rollback unless object.errors.count.zero?
end
payment = if price[:schedule]
PaymentScheduleService.new.create(
subscription&.to_object,
price[:before_coupon],
coupon: @coupon,
operator: @operator,
payment_method: @payment_method,
user: @customer,
reservation: reservation&.to_object,
payment_id: payment_id,
payment_type: payment_type
)
else
InvoicesService.create(
price,
@operator.invoicing_profile.id,
reservation: reservation&.to_object,
payment_id: payment_id,
payment_type: payment_type,
payment_method: @payment_method
)
end
payment.save
payment.post_save(payment_id)
end
objects.map(&:errors).flatten.count.zero?
end
end end

View File

@ -16,23 +16,11 @@ class Subscription < ApplicationRecord
validates_with SubscriptionGroupValidator validates_with SubscriptionGroupValidator
# creation # creation
before_create :set_expiration_date
after_save :notify_member_subscribed_plan after_save :notify_member_subscribed_plan
after_save :notify_admin_subscribed_plan after_save :notify_admin_subscribed_plan
after_save :notify_partner_subscribed_plan, if: :of_partner_plan? after_save :notify_partner_subscribed_plan, if: :of_partner_plan?
after_commit :update_credits, on: :create
##
# Set the inner properties of the subscription, init the user's credits and save the subscription into the DB
# @return {boolean} true, if the operation succeeded
##
def init_save
return false unless valid?
set_expiration_date
return false unless save
UsersCredits::Manager.new(user: user).reset_credits
true
end
def generate_and_save_invoice(operator_profile_id) def generate_and_save_invoice(operator_profile_id)
generate_invoice(operator_profile_id).save generate_invoice(operator_profile_id).save
@ -148,4 +136,9 @@ class Subscription < ApplicationRecord
def of_partner_plan? def of_partner_plan?
plan.is_a?(PartnerPlan) plan.is_a?(PartnerPlan)
end end
# init the user's credits
def update_credits
UsersCredits::Manager.new(user: user).reset_credits
end
end end

View File

@ -8,7 +8,7 @@ class CartService
## ##
# For details about the expected hash format # For details about the expected hash format
# @see app/frontend/src/javascript/models/payment.ts > interface CartItems # @see app/frontend/src/javascript/models/payment.ts > interface ShoppingCart
## ##
def from_hash(cart_items) def from_hash(cart_items)
@customer = customer(cart_items) @customer = customer(cart_items)
@ -17,7 +17,7 @@ class CartService
items = [] items = []
cart_items[:items].each do |item| cart_items[:items].each do |item|
if item.keys.first == 'subscription' if item.keys.first == 'subscription'
items.push(CartItem::Subscription.new(plan_info[:plan])) if plan_info[:new_subscription] items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) if plan_info[:new_subscription]
elsif item.keys.first == 'reservation' elsif item.keys.first == 'reservation'
items.push(reservable_from_hash(item[:reservation], plan_info)) items.push(reservable_from_hash(item[:reservation], plan_info))
end end
@ -28,6 +28,7 @@ class CartService
ShoppingCart.new( ShoppingCart.new(
@customer, @customer,
@operator,
coupon, coupon,
schedule, schedule,
cart_items[:payment_method], cart_items[:payment_method],

View File

@ -1,84 +0,0 @@
# frozen_string_literal: true
# Provides helper methods for Reservation actions
class Reservations::Reserve
attr_accessor :user_id, :operator_profile_id
def initialize(user_id, operator_profile_id)
@user_id = user_id
@operator_profile_id = operator_profile_id
end
##
# Confirm the payment of the given reservation, generate the associated documents and save the record into
# the database.
##
def pay_and_save(reservation, payment_details: nil, payment_id: nil, payment_type: nil, schedule: false, payment_method: nil)
user = User.find(user_id)
reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
ActiveRecord::Base.transaction do
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon: payment_details[:coupon],
payment_id: payment_id,
payment_type: payment_type)
else
generate_invoice(reservation,
operator_profile_id,
payment_details,
payment_id: payment_id,
payment_type: payment_type,
payment_method: payment_method)
end
WalletService.debit_user_wallet(payment, user, reservation)
reservation.save
reservation.post_save
payment.save
payment.post_save(payment_id)
end
true
end
private
##
# Generate the invoice for the given reservation+subscription
##
def generate_schedule(reservation: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon: nil,
payment_id: nil, payment_type: nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
PaymentScheduleService.new.create(
nil,
total,
coupon: coupon,
operator: operator,
payment_method: payment_method,
user: user,
reservation: reservation,
payment_id: payment_id,
payment_type: payment_type
)
end
##
# Generate the invoice for the given reservation
##
def generate_invoice(reservation, operator_profile_id, payment_details, payment_id: nil, payment_type: nil, payment_method: nil)
InvoicesService.create(
payment_details,
operator_profile_id,
reservation: reservation,
payment_id: payment_id,
payment_type: payment_type,
payment_method: payment_method
)
end
end

View File

@ -9,48 +9,6 @@ class Subscriptions::Subscribe
@operator_profile_id = operator_profile_id @operator_profile_id = operator_profile_id
end end
##
# @param subscription {Subscription}
# @param payment_details {Hash} as generated by ShoppingCart.total
# @param payment_id {String} from the payment gateway
# @param payment_type {String} the object type of payment_id
# @param schedule {Boolean}
# @param payment_method {String}
##
def pay_and_save(subscription, payment_details: nil, payment_id: nil, payment_type: nil, schedule: false, payment_method: nil)
return false if user_id.nil?
user = User.find(user_id)
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
ActiveRecord::Base.transaction do
subscription.init_save
raise InvalidSubscriptionError unless subscription&.persisted?
payment = if schedule
generate_schedule(subscription: subscription,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon: payment_details[:coupon],
payment_id: payment_id,
payment_type: payment_type)
else
generate_invoice(subscription,
operator_profile_id,
payment_details,
payment_id: payment_id,
payment_type: payment_type,
payment_method: payment_method)
end
WalletService.debit_user_wallet(payment, user, subscription)
payment.save
payment.post_save(payment_id)
end
true
end
def extend_subscription(subscription, new_expiration_date, free_days) def extend_subscription(subscription, new_expiration_date, free_days)
return subscription.free_extend(new_expiration_date, @operator_profile_id) if free_days return subscription.free_extend(new_expiration_date, @operator_profile_id) if free_days
@ -76,17 +34,23 @@ class Subscriptions::Subscribe
details = cart.total details = cart.total
payment = if schedule payment = if schedule
generate_schedule(subscription: new_sub, operator = InvoicingProfile.find(operator_profile_id)&.user
total: details[:before_coupon],
operator_profile_id: operator_profile_id, PaymentScheduleService.new.create(
user: new_sub.user, new_sub,
payment_method: schedule.payment_method, details[:before_coupon],
payment_id: schedule.gateway_payment_mean&.id, operator: operator,
payment_type: schedule.gateway_payment_mean&.class) payment_method: schedule.payment_method,
user: new_sub.user,
payment_id: schedule.gateway_payment_mean&.id,
payment_type: schedule.gateway_payment_mean&.class
)
else else
generate_invoice(subscription, InvoicesService.create(
operator_profile_id, details,
details) operator_profile_id,
subscription: new_sub
)
end end
payment.save payment.save
payment.post_save(schedule&.gateway_payment_mean&.id) payment.post_save(schedule&.gateway_payment_mean&.id)
@ -95,40 +59,4 @@ class Subscriptions::Subscribe
end end
false false
end end
private
##
# Generate the invoice for the given subscription
##
def generate_schedule(subscription: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon: nil,
payment_id: nil, payment_type: nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
PaymentScheduleService.new.create(
subscription,
total,
coupon: coupon,
operator: operator,
payment_method: payment_method,
user: user,
payment_id: payment_id,
payment_type: payment_type
)
end
##
# Generate the invoice for the given subscription
##
def generate_invoice(subscription, operator_profile_id, payment_details, payment_id: nil, payment_type: nil, payment_method: nil)
InvoicesService.create(
payment_details,
operator_profile_id,
subscription: subscription,
payment_id: payment_id,
payment_type: payment_type,
payment_method: payment_method
)
end
end end

View File

@ -7,7 +7,8 @@ class ReservationSlotSubscriptionValidator < ActiveModel::Validator
if record.user.subscribed_plan && s.availability.plan_ids.include?(record.user.subscribed_plan.id) if record.user.subscribed_plan && s.availability.plan_ids.include?(record.user.subscribed_plan.id)
elsif s.availability.plan_ids.include?(record.plan_id) elsif s.availability.plan_ids.include?(record.plan_id)
else else
record.errors[:slots] << 'slot is restrict for subscriptions' # FIXME, admin should be able to reserve anyway
# record.errors[:slots] << 'slot is restrict for subscriptions'
end end
end end
end end

View File

@ -1,6 +1,6 @@
class SubscriptionGroupValidator < ActiveModel::Validator class SubscriptionGroupValidator < ActiveModel::Validator
def validate(record) def validate(record)
return if record.statistic_profile.group_id == record.plan.group_id return if record.statistic_profile&.group_id == record.plan&.group_id
record.errors[:plan_id] << "This plan is not compatible with the current user's group" record.errors[:plan_id] << "This plan is not compatible with the current user's group"
end end

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'integrity/archive_helper'
# This migration will ensure data integrity for invoices.
# A bug introduced with v4.7.0 has made invoices without invoiced_id for Reservations.
# This issue is concerning slots restricted to subscribers, when the restriction was manually overridden by an admin.
class FixInvoicesWithoutInvoicedId < ActiveRecord::Migration[5.2]
def up
return unless Invoice.where(invoiced_id: nil).count.positive?
# check the footprints and save the archives
Integrity::ArchiveHelper.check_footprints
periods = Integrity::ArchiveHelper.backup_and_remove_periods
# fix invoices data
Invoice.where(invoiced_id: nil).each do |invoice|
if invoice.invoiced_type != 'Reservation'
STDERR.puts "WARNING: Invoice #{invoice.id} is not about a reservation, ignoring..."
next
end
ii = invoice.invoice_items.where(subscription_id: nil).first
reservable = find_reservable(ii)
if reservable
if reservable.is_a? Event
STDERR.puts "WARNING: invoice #{invoice.id} may be linked to the Event #{reservable.id}. This is unsupported, ignoring..."
next
end
::Reservation.create!(
reservable_id: reservable.id,
reservable_type: reservable.class.name,
slots_attributes: slots_attributes(invoice, reservable),
statistic_profile_id: StatisticProfile.find_by(user: invoice.user).id
)
invoice.update_attributes(invoiced: reservation)
else
STDERR.puts "WARNING: Unable to guess the reservable for invoice #{invoice.id}, ignoring..."
end
end
# chain records
puts 'Chaining all record. This may take a while...'
InvoiceItem.order(:id).all.each(&:chain_record)
Invoice.order(:id).all.each(&:chain_record)
# re-create all archives from the memory dump
Integrity::ArchiveHelper.restore_periods(periods)
end
def down; end
private
def find_reservable(invoice_item)
descr = /^([a-zA-Z\u00C0-\u017F]+\s+)+/.match(invoice_item.description)[0].strip[/(.*)\s/, 1]
reservable = InvoiceItem.where('description LIKE ?', "#{descr}%")
.map(&:invoice)
.filter { |i| !i.invoiced_id.nil? }
.map(&:invoiced)
.map(&:reservable)
.first
reservable ||= [Machine, Training, Space].map { |c| c.where('name LIKE ?', "#{descr}%") }
.filter { |r| r.count.positive? }
.first
&.first
reservable || Event.where('title LIKE ?', "#{descr}%").first
end
def find_slots(invoice)
invoice.invoice_items.map do |ii|
start = DateTime.parse(ii.description)
end_time = DateTime.parse(/- (.+)$/.match(ii.description)[1])
[start, DateTime.new(start.year, start.month, start.day, end_time.hour, end_time.min, end_time.sec, end_time.zone)]
end
end
def find_availability(reservable, slot)
return if reservable.is_a? Event
availability = reservable.availabilities.where('start_at <= ? AND end_at >= ?', slot[0], slot[1]).first
unless availability
STDERR.puts "WARNING: Unable to find an availability for #{reservable.class.name} #{reservable.id}, at #{slot[0]}..."
end
availability
end
def slots_attributes(invoice, reservable)
find_slots(invoice).map do |slot|
{
start_at: slot[0],
end_at: slot[1],
availability_id: find_availability(reservable, slot).id,
offered: invoice.total.zero?
}
end
end
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'integrity/archive_helper'
# Previously, the relation between an invoice and the bought objects where stored disparately:
# Invoice.invoiced_id & Invoice.invoiced_type saved the main object, and if a subscription was took at the same time of
# a reservation (the only case where two object were bought at the same time), the reservation was saved in
# Invoice.invoiced and the subscription was saved in InvoiceItem.subscription_id
#
# From this migration, everything will be saved in InvoiceItems.object_id & InvoiceItem.object_type. This will be more
# extensible and will allow to invoice more types of objects the future.
class AddObjectToInvoiceItem < ActiveRecord::Migration[5.2]
def up
# first, check the footprints
Integrity::ArchiveHelper.check_footprints
# if everything is ok, proceed with migration: remove and save periods in memory
periods = Integrity::ArchiveHelper.backup_and_remove_periods
add_reference :invoice_items, :object, polymorphic: true
add_column :invoice_items, :main, :boolean
# migrate data
Invoice.where.not(invoiced_type: 'Reservation').each do |invoice|
invoice.invoice_items.first.update_attributes(
object_id: invoice.invoiced_id,
object_type: invoice.invoiced_type,
main: true
)
end
Invoice.where(invoiced_type: 'Reservation').each do |invoice|
invoice.invoice_items.first.update_attributes(
object_id: invoice.invoiced_id,
object_type: invoice.invoiced_type,
main: true
)
end
remove_column :invoice_items, :subscription_id
remove_reference :invoices, :invoiced
# chain records
puts 'Chaining all record. This may take a while...'
InvoiceItem.order(:id).all.each(&:chain_record)
Invoice.order(:id).all.each(&:chain_record)
# re-create all archives from the memory dump
Integrity::ArchiveHelper.restore_periods(periods)
end
def down
# first, check the footprints
Integrity::ArchiveHelper.check_footprints
# if everything is ok, proceed with migration: remove and save periods in memory
periods = Integrity::ArchiveHelper.backup_and_remove_periods
add_column :invoice_items, :subscription_id, :integer
add_reference :invoices, :invoiced, polymorphic: true
# migrate data
remove_column :invoice_items, :main
remove_reference :invoice_items, :object
# chain records
puts 'Chaining all record. This may take a while...'
InvoiceItem.order(:id).all.each(&:chain_record)
Invoice.order(:id).all.each(&:chain_record)
# re-create all archives from the memory dump
Integrity::ArchiveHelper.restore_periods(periods)
end
end

View File

@ -5160,6 +5160,22 @@ CREATE INDEX profiles_lower_unaccent_last_name_trgm_idx ON public.profiles USING
CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector); CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector);
--
-- Name: accounting_periods accounting_periods_del_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_del_protect AS
ON DELETE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: accounting_periods accounting_periods_upd_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_upd_protect AS
ON UPDATE TO public.accounting_periods DO INSTEAD NOTHING;
-- --
-- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: - -- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: -
-- --

View File

@ -2,6 +2,8 @@
require 'test_helper' require 'test_helper'
module Reservations; end
class Reservations::CreateAsAdminTest < ActionDispatch::IntegrationTest class Reservations::CreateAsAdminTest < ActionDispatch::IntegrationTest
setup do setup do
@user_without_subscription = User.members.without_subscription.first @user_without_subscription = User.members.without_subscription.first

View File

@ -2,6 +2,8 @@
require 'test_helper' require 'test_helper'
module Reservations; end
class Reservations::CreateTest < ActionDispatch::IntegrationTest class Reservations::CreateTest < ActionDispatch::IntegrationTest
setup do setup do
@user_without_subscription = User.members.without_subscription.first @user_without_subscription = User.members.without_subscription.first