1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-03-21 12:29:03 +01:00

Merge remote-tracking branch 'origin/spaces_multiprices' into dev

This commit is contained in:
Du Peng 2021-12-23 09:34:55 +01:00
commit 2ec8a63af9
48 changed files with 813 additions and 120 deletions

View File

@ -1,5 +1,8 @@
# Changelog Fab-manager
## v5.1.14 2021 December 21
- Ability to configure prices for spaces by time slots different than the default hourly rate
- Updated portuguese translation
- Refactored the ReserveButton component to use the same user's data across all the component
- First optimization the load time of the payment schedules list

View File

@ -4,6 +4,20 @@
# Prices are used in reservations (Machine, Space)
class API::PricesController < API::ApiController
before_action :authenticate_user!
before_action :set_price, only: %i[update destroy]
def create
@price = Price.new(price_create_params)
@price.amount *= 100
authorize @price
if @price.save
render json: @price, status: :created
else
render json: @price.errors, status: :unprocessable_entity
end
end
def index
@prices = PriceService.list(params)
@ -11,7 +25,6 @@ class API::PricesController < API::ApiController
def update
authorize Price
@price = Price.find(params[:id])
price_parameters = price_params
price_parameters[:amount] = price_parameters[:amount] * 100
if @price.update(price_parameters)
@ -21,6 +34,12 @@ class API::PricesController < API::ApiController
end
end
def destroy
authorize @price
@price.destroy
head :no_content
end
def compute
cs = CartService.new(current_user)
cart = cs.from_hash(params)
@ -29,7 +48,15 @@ class API::PricesController < API::ApiController
private
def set_price
@price = Price.find(params[:id])
end
def price_create_params
params.require(:price).permit(:amount, :duration, :group_id, :plan_id, :priceable_id, :priceable_type)
end
def price_params
params.require(:price).permit(:amount)
params.require(:price).permit(:amount, :duration)
end
end

View File

@ -14,11 +14,21 @@ export default class PriceAPI {
return res?.data;
}
static async create (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.post('/api/prices', { price });
return res?.data;
}
static async update (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
return res?.data;
}
static async destroy (priceId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/prices/${priceId}`);
return res?.data;
}
private static filtersToQuery (filters?: PriceIndexFilter): string {
if (!filters) return '';

View File

@ -0,0 +1,15 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Space } from '../models/space';
export default class SpaceAPI {
static async index (): Promise<Array<any>> {
const res: AxiosResponse<Array<Space>> = await apiClient.get('/api/spaces');
return res?.data;
}
static async get (id: number): Promise<Space> {
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
return res?.data;
}
}

View File

@ -1,12 +1,12 @@
import React, { ReactNode, useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../base/fab-popover';
import { FabPopover } from '../../base/fab-popover';
import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { DeletePack } from './delete-pack';
import { EditPack } from './edit-pack';
import FormatLib from '../../lib/format';
import FormatLib from '../../../lib/format';
interface ConfigurePacksButtonProps {
packsData: Array<PrepaidPack>,
@ -64,8 +64,8 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
};
return (
<div className="configure-packs-button">
<button className="packs-button" onClick={toggleShowList}>
<div className="configure-group">
<button className="configure-group-button" onClick={toggleShowList}>
<i className="fas fa-box" />
</button>
{showList && <FabPopover title={t('app.admin.configure_packs_button.packs')} headerButton={renderAddButton()} className="fab-popover__right">
@ -73,7 +73,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
{packs?.map(p =>
<li key={p.id} className={p.disabled ? 'disabled' : ''}>
{formatDuration(p.minutes)} - {FormatLib.price(p.amount)}
<span className="pack-actions">
<span className="group-actions">
<EditPack onSuccess={handleSuccess} onError={onError} pack={p} />
<DeletePack onSuccess={handleSuccess} onError={onError} pack={p} />
</span>

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabAlert } from '../../base/fab-alert';
interface CreatePackProps {
onSuccess: (message: string) => void,

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { Loader } from '../base/loader';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { FabButton } from '../../base/fab-button';
import { FabModal } from '../../base/fab-modal';
import { Loader } from '../../base/loader';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
interface DeletePackProps {
onSuccess: (message: string) => void,
@ -42,8 +42,8 @@ const DeletePackComponent: React.FC<DeletePackProps> = ({ onSuccess, onError, pa
};
return (
<div className="delete-pack">
<FabButton type='button' className="remove-pack-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<div className="delete-group">
<FabButton type='button' className="delete-group-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_pack.delete_pack')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FabButton } from '../../base/fab-button';
interface EditPackProps {
pack: PrepaidPack,
@ -54,16 +54,15 @@ export const EditPack: React.FC<EditPackProps> = ({ pack, onSuccess, onError })
};
return (
<div className="edit-pack">
<FabButton type='button' className="edit-pack-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<div className="edit-group">
<FabButton type='button' icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_pack.edit_pack')}
className="edit-pack-modal"
closeButton
confirmButton={t('app.admin.edit_pack.confirm_changes')}
onConfirmSendFormId="edit-pack">
{packData && <PackForm formId="edit-pack" onSubmit={handleUpdate} pack={packData} />}
onConfirmSendFormId="edit-group">
{packData && <PackForm formId="edit-group" onSubmit={handleUpdate} pack={packData} />}
</FabModal>
</div>
);

View File

@ -1,22 +1,22 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { FabAlert } from '../base/fab-alert';
import { HtmlTranslate } from '../base/html-translate';
import MachineAPI from '../../api/machine';
import GroupAPI from '../../api/group';
import { Machine } from '../../models/machine';
import { Group } from '../../models/group';
import { IApplication } from '../../models/application';
import { EditablePrice } from './editable-price';
import { Loader } from '../../base/loader';
import { FabAlert } from '../../base/fab-alert';
import { HtmlTranslate } from '../../base/html-translate';
import MachineAPI from '../../../api/machine';
import GroupAPI from '../../../api/group';
import { Machine } from '../../../models/machine';
import { Group } from '../../../models/group';
import { IApplication } from '../../../models/application';
import { EditablePrice } from '../editable-price';
import { ConfigurePacksButton } from './configure-packs-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../models/prepaid-pack';
import PriceAPI from '../../../api/price';
import { Price } from '../../../models/price';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useImmer } from 'use-immer';
import FormatLib from '../../lib/format';
import FormatLib from '../../../lib/format';
declare const Application: IApplication;
@ -107,7 +107,7 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
};
return (
<div className="machines-pricing">
<div className="pricing-list">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>

View File

@ -1,11 +1,11 @@
import React, { BaseSyntheticEvent } from 'react';
import Select from 'react-select';
import Switch from 'react-switch';
import { PrepaidPack } from '../../models/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../base/fab-input';
import { IFablab } from '../../models/fablab';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab;
@ -103,7 +103,7 @@ export const PackForm: React.FC<PackFormProps> = ({ formId, onSubmit, pack }) =>
};
return (
<form id={formId} onSubmit={handleSubmit} className="pack-form">
<form id={formId} onSubmit={handleSubmit} className="group-form">
<label htmlFor="hours">{t('app.admin.pack_form.hours')} *</label>
<FabInput id="hours"
type="number"

View File

@ -0,0 +1,79 @@
import React, { ReactNode, useState } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../../base/fab-popover';
import { CreateExtendedPrice } from './create-extended-price';
import PriceAPI from '../../../api/price';
import FormatLib from '../../../lib/format';
import { EditExtendedPrice } from './edit-extended-price';
import { DeleteExtendedPrice } from './delete-extended-price';
interface ConfigureExtendedPriceButtonProps {
prices: Array<Price>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component is a button that shows the list of extendedPrices.
* It also triggers modal dialogs to configure (add/edit/remove) extendedPrices.
*/
export const ConfigureExtendedPriceButton: React.FC<ConfigureExtendedPriceButtonProps> = ({ prices, onError, onSuccess, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [extendedPrices, setExtendedPrices] = useState<Array<Price>>(prices);
const [showList, setShowList] = useState<boolean>(false);
/**
* Open/closes the popover listing the existing extended prices
*/
const toggleShowList = (): void => {
setShowList(!showList);
};
/**
* Callback triggered when the extendedPrice was successfully created/deleted/updated.
* We refresh the list of extendedPrices.
*/
const handleSuccess = (message: string) => {
onSuccess(message);
PriceAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
.then(data => setExtendedPrices(data.filter(p => p.duration !== 60)))
.catch(error => onError(error));
};
/**
* Render the button used to trigger the "new extended price" modal
*/
const renderAddButton = (): ReactNode => {
return <CreateExtendedPrice onSuccess={handleSuccess}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
};
return (
<div className="configure-group">
<button className="configure-group-button" onClick={toggleShowList}>
<i className="fas fa-stopwatch" />
</button>
{showList && <FabPopover title={t('app.admin.configure_extendedPrices_button.extendedPrices')} headerButton={renderAddButton()} className="fab-popover__right">
<ul>
{extendedPrices?.map(extendedPrice =>
<li key={extendedPrice.id}>
{extendedPrice.duration} {t('app.admin.calendar.minutes')} - {FormatLib.price(extendedPrice.amount)}
<span className="group-actions">
<EditExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
<DeleteExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
</span>
</li>)}
</ul>
{extendedPrices?.length === 0 && <span>{t('app.admin.configure_extendedPrices_button.no_extendedPrices')}</span>}
</FabPopover>}
</div>
);
};

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../../base/fab-alert';
interface CreateExtendedPriceProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new extended price
*/
export const CreateExtendedPrice: React.FC<CreateExtendedPriceProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Open/closes the "new extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Callback triggered when the user has validated the creation of the new extended price
*/
const handleSubmit = (extendedPrice: Price): void => {
// set the already-known attributes of the new extended price
const newExtendedPrice = Object.assign<Price, Price>({} as Price, extendedPrice);
newExtendedPrice.group_id = groupId;
newExtendedPrice.priceable_id = priceableId;
newExtendedPrice.priceable_type = priceableType;
// create it on the API
PriceAPI.create(newExtendedPrice)
.then(() => {
onSuccess(t('app.admin.create_extendedPrice.extendedPrice_successfully_created'));
toggleModal();
})
.catch(error => onError(error));
};
return (
<div className="create-pack">
<button className="add-pack-button" onClick={toggleModal}><i className="fas fa-plus"/></button>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.create_extendedPrice.new_extendedPrice')}
className="new-pack-modal"
closeButton
confirmButton={t('app.admin.create_extendedPrice.create_extendedPrice')}
onConfirmSendFormId="new-extended-price">
<FabAlert level="info">
{t('app.admin.create_extendedPrice.new_extendedPrice_info', { TYPE: priceableType })}
</FabAlert>
<ExtendedPriceForm formId="new-extended-price" onSubmit={handleSubmit} />
</FabModal>
</div>
);
};

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
import { FabModal } from '../../base/fab-modal';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
interface DeleteExtendedPriceProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
price: Price,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided extended price.
*/
export const DeleteExtendedPrice: React.FC<DeleteExtendedPriceProps> = ({ onSuccess, onError, price }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the temporary set extended price
*/
const onDeleteConfirmed = (): void => {
PriceAPI.destroy(price.id).then(() => {
onSuccess(t('app.admin.delete_extendedPrice.extendedPrice_deleted'));
}).catch((error) => {
onError(t('app.admin.delete_extendedPrice.unable_to_delete') + error);
});
toggleDeletionModal();
};
return (
<div className="delete-group">
<FabButton type='button' className="delete-group-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_extendedPrice.delete_extendedPrice')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.delete_extendedPrice.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.delete_extendedPrice.delete_confirmation')}</span>
</FabModal>
</div>
);
};

View File

@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
interface EditExtendedPriceProps {
price: Price,
onSuccess: (message: string) => void,
onError: (message: string) => void
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new extended price
*/
export const EditExtendedPrice: React.FC<EditExtendedPriceProps> = ({ price, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
const [extendedPriceData, setExtendedPriceData] = useState<Price>(price);
/**
* Open/closes the "edit extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* When the user clicks on the edition button open te edition modal
*/
const handleRequestEdit = (): void => {
toggleModal();
};
/**
* Callback triggered when the user has validated the changes of the extended price
*/
const handleUpdate = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.edit_extendedPrice.extendedPrice_successfully_updated'));
setExtendedPriceData(price);
toggleModal();
})
.catch(error => onError(error));
};
return (
<div className="edit-group">
<FabButton type='button' icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_extendedPrice.edit_extendedPrice')}
closeButton
confirmButton={t('app.admin.edit_extendedPrice.confirm_changes')}
onConfirmSendFormId="edit-group">
{extendedPriceData && <ExtendedPriceForm formId="edit-group" onSubmit={handleUpdate} price={extendedPriceData} />}
</FabModal>
</div>
);
};

View File

@ -0,0 +1,74 @@
import React, { BaseSyntheticEvent } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab;
interface ExtendedPriceFormProps {
formId: string,
onSubmit: (pack: Price) => void,
price?: Price,
}
/**
* A form component to create/edit a extended price.
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const ExtendedPriceForm: React.FC<ExtendedPriceFormProps> = ({ formId, onSubmit, price }) => {
const [extendedPriceData, updateExtendedPriceData] = useImmer<Price>(price || {} as Price);
const { t } = useTranslation('admin');
/**
* Callback triggered when the user sends the form.
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(extendedPriceData);
};
/**
* Callback triggered when the user inputs an amount for the current extended price.
*/
const handleUpdateAmount = (amount: string) => {
updateExtendedPriceData(draft => {
draft.amount = parseFloat(amount);
});
};
/**
* Callback triggered when the user inputs a number of minutes for the current extended price.
*/
const handleUpdateHours = (minutes: string) => {
updateExtendedPriceData(draft => {
draft.duration = parseInt(minutes, 10);
});
};
return (
<form id={formId} onSubmit={handleSubmit} className="group-form">
<label htmlFor="duration">{t('app.admin.calendar.minutes')} *</label>
<FabInput id="duration"
type="number"
defaultValue={extendedPriceData?.duration || ''}
onChange={handleUpdateHours}
step={1}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.extended_price_form.amount')} *</label>
<FabInput id="amount"
type="number"
step={0.01}
min={0}
defaultValue={extendedPriceData?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
</form>
);
};

View File

@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../../base/loader';
import { FabAlert } from '../../base/fab-alert';
import { HtmlTranslate } from '../../base/html-translate';
import SpaceAPI from '../../../api/space';
import GroupAPI from '../../../api/group';
import { Group } from '../../../models/group';
import { IApplication } from '../../../models/application';
import { Space } from '../../../models/space';
import { EditablePrice } from '../editable-price';
import { ConfigureExtendedPriceButton } from './configure-extended-price-button';
import PriceAPI from '../../../api/price';
import { Price } from '../../../models/price';
import { useImmer } from 'use-immer';
import FormatLib from '../../../lib/format';
declare const Application: IApplication;
interface SpacesPricingProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Interface to set and edit the prices of spaces-hours, per group
*/
const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [spaces, setSpaces] = useState<Array<Space>>(null);
const [groups, setGroups] = useState<Array<Group>>(null);
const [prices, updatePrices] = useImmer<Array<Price>>([]);
// retrieve the initial data
useEffect(() => {
SpaceAPI.index()
.then(data => setSpaces(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
.then(data => updatePrices(data))
.catch(error => onError(error));
}, []);
// duration of the example slot
const EXEMPLE_DURATION = 20;
/**
* Return the exemple price, formatted
*/
const examplePrice = (type: 'hourly_rate' | 'final_price'): string => {
const hourlyRate = 10;
if (type === 'hourly_rate') {
return FormatLib.price(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return FormatLib.price(price);
};
/**
* Find the default price (hourly rate) matching the given criterion
*/
const findPriceBy = (spaceId, groupId): Price => {
return prices.find(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration === 60);
};
/**
* Find prices matching the given criterion, except the default hourly rate
*/
const findExtendedPricesBy = (spaceId, groupId): Array<Price> => {
return prices.filter(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration !== 60);
};
/**
* Update the given price in the internal state
*/
const updatePrice = (price: Price): void => {
updatePrices(draft => {
const index = draft.findIndex(p => p.id === price.id);
draft[index] = price;
return draft;
});
};
/**
* Callback triggered when the user has confirmed to update a price
*/
const handleUpdatePrice = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.spaces_pricing.price_updated'));
updatePrice(price);
})
.catch(error => onError(error));
};
return (
<div className="pricing-list">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.pricing.these_prices_match_space_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.pricing.you_can_override')}</p>
</FabAlert>
<table>
<thead>
<tr>
<th>{t('app.admin.pricing.spaces')}</th>
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
</tr>
</thead>
<tbody>
{spaces?.map(space => <tr key={space.id}>
<td>{space.name}</td>
{groups?.map(group => <td key={group.id}>
{prices.length && <EditablePrice price={findPriceBy(space.id, group.id)} onSave={handleUpdatePrice} />}
<ConfigureExtendedPriceButton
prices={findExtendedPricesBy(space.id, group.id)}
onError={onError}
onSuccess={onSuccess}
groupId={group.id}
priceableId={space.id}
priceableType='Space' />
</td>)}
</tr>)}
</tbody>
</table>
</div>
);
};
const SpacesPricingWrapper: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
return (
<Loader>
<SpacesPricing onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('spacesPricing', react2angular(SpacesPricingWrapper, ['onError', 'onSuccess']));

View File

@ -461,7 +461,7 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
*/
$scope.findPriceBy = function (prices, machineId, groupId) {
for (const price of Array.from(prices)) {
if ((price.priceable_id === machineId) && (price.group_id === groupId)) {
if ((price.priceable_id === machineId) && (price.group_id === groupId) && (price.duration === 60)) {
return price;
}
}

View File

@ -11,7 +11,8 @@ export interface Price {
plan_id: number,
priceable_type: string,
priceable_id: number,
amount: number
amount: number,
duration?: number // in minutes
}
export interface ComputePriceResult {

View File

@ -111,7 +111,8 @@ export enum SettingName {
PublicAgendaModule = 'public_agenda_module',
RenewPackThreshold = 'renew_pack_threshold',
PackOnlyForSubscription = 'pack_only_for_subscription',
OverlappingCategories = 'overlapping_categories'
OverlappingCategories = 'overlapping_categories',
ExtendedPricesInSameDay = 'extended_prices_in_same_day'
}
export type SettingValue = string|boolean|number;

View File

@ -0,0 +1,15 @@
export interface Space {
id: number,
name: string,
description: string,
slug: string,
default_places: number,
disabled: boolean,
space_image: string,
space_file_attributes?: {
id: number,
attachment: string,
attachement_url: string,
}
}

View File

@ -1080,7 +1080,7 @@ angular.module('application.router', ['ui.router'])
"'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " +
"'display_name_enable', 'machines_sort_by', 'fab_analytics', 'statistics_module', 'address_required', " +
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," +
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories']"
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'extended_prices_in_same_day']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

View File

@ -57,12 +57,12 @@
@import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal";
@import "modules/user/avatar";
@import "modules/pricing/machines-pricing";
@import "modules/pricing/pricing-list";
@import "modules/pricing/editable-price";
@import "modules/pricing/configure-packs-button";
@import "modules/pricing/pack-form";
@import "modules/pricing/delete-pack";
@import "modules/pricing/edit-pack";
@import "modules/pricing/configure-group-button";
@import "modules/pricing/group-form";
@import "modules/pricing/delete-group";
@import "modules/pricing/edit-group";
@import "modules/settings/check-list-setting";
@import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary";

View File

@ -1,9 +1,9 @@
.configure-packs-button {
.configure-group {
display: inline-block;
margin-left: 6px;
position: relative;
.packs-button {
&-button {
border: 1px solid #d0cccc;
border-radius: 50%;
cursor: pointer;
@ -44,7 +44,7 @@
line-height: 24px;
}
.pack-actions button {
.group-actions button {
font-size: 10px;
vertical-align: middle;
line-height: 10px;

View File

@ -1,7 +1,7 @@
.delete-pack {
.delete-group {
display: inline;
.remove-pack-button {
&-button {
background-color: #cb1117;
color: white;
}

View File

@ -1,4 +1,4 @@
.pack-form {
.group-form {
.interval-inputs {
display: flex;

View File

@ -1,4 +1,4 @@
.machines-pricing {
.pricing-list {
.fab-alert {
margin: 15px 0;
}
@ -28,4 +28,4 @@
border-top: 1px solid #ddd;
}
}
}
}

View File

@ -1,29 +1 @@
<div class="alert alert-warning m-t">
<p ng-bind-html="'app.admin.pricing.these_prices_match_space_hours_rates_html' | translate"></p>
<p ng-bind-html="'app.admin.pricing.prices_calculated_on_hourly_rate_html' | translate:{ DURATION:slotDuration, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }"></p>
<p translate>{{ 'app.admin.pricing.you_can_override' }}</p>
</div>
<table class="table">
<thead>
<tr>
<th style="width:20%" translate>{{ 'app.admin.pricing.spaces' }}</th>
<th style="width:20%" ng-repeat="group in enabledGroups">
<span class="text-u-c text-sm">{{group.name}}</span>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="space in enabledSpaces">
<td>
{{ space.name }}
</td>
<td ng-repeat="group in enabledGroups">
<span editable-number="findPriceBy(spacesPrices, space.id, group.id).amount"
e-step="any"
onbeforesave="updatePrice($data, findPriceBy(spacesPrices, space.id, group.id))">
{{ findPriceBy(spacesPrices, space.id, group.id).amount | currency}}
</span>
</td>
</tr>
</tbody>
</table>
<spaces-pricing on-success="onSuccess" on-error="onError">

View File

@ -117,6 +117,28 @@
required="true">
</number-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.extended_prices' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.extended_prices_info_html' | translate"></p>
<boolean-setting name="extended_prices_in_same_day"
settings="allSettings"
label="app.admin.settings.extended_prices_in_same_day"
classes="m-l">
</boolean-setting>
</div>
</div>
</div>
</div>
@ -170,6 +192,8 @@
label="app.admin.settings.show_event"
classes="m-l"></boolean-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.display_invite_to_renew_pack' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.packs_threshold_info_html' | translate"></p>
@ -182,14 +206,5 @@
step="0.01">
</number-setting>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
</div>
</div>

View File

@ -3,7 +3,7 @@
MINUTES_PER_HOUR = 60.0
SECONDS_PER_MINUTE = 60.0
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 } }.freeze
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 }, custom_duration: nil }.freeze
# A generic reservation added to the shopping cart
class CartItem::Reservation < CartItem::BaseItem
@ -16,19 +16,19 @@ class CartItem::Reservation < CartItem::BaseItem
end
def price
base_amount = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id)).amount
is_privileged = @operator.privileged? && @operator.id != @customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) }
prices = applicable_prices
elements = { slots: [] }
amount = 0
hours_available = credits
@slots.each_with_index do |slot, index|
amount += get_slot_price(base_amount, slot, is_privileged,
elements: elements,
has_credits: (index < hours_available),
prepaid: prepaid)
amount += get_slot_price_from_prices(prices, slot, is_privileged,
elements: elements,
has_credits: (index < hours_available),
prepaid: prepaid)
end
{ elements: elements, amount: amount }
@ -61,6 +61,27 @@ class CartItem::Reservation < CartItem::BaseItem
0
end
##
# Compute the price of a single slot, according to the list of applicable prices.
# @param prices {{ prices: Array<{price: Price, duration: number}> }} list of prices to use with the current reservation
# @see get_slot_price
##
def get_slot_price_from_prices(prices, slot, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_minutes = (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
# apply the base price to the real slot duration
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
price[:duration] -= slot_minutes
real_price
end
##
# Compute the price of a single slot, according to the base price and the ability for an admin
# to offer the slot.
@ -103,6 +124,35 @@ class CartItem::Reservation < CartItem::BaseItem
real_price
end
# We determine the list of prices applicable to current reservation
# The longest available price is always used in priority.
# Eg. If the reservation is for 12 hours, and there are prices for 3 hours, 7 hours,
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
# All these prices are returned to be applied to the reservation.
def applicable_prices
all_slots_in_same_day = @slots.map { |slot| slot[:start_at].to_date }.uniq.size == 1
total_duration = @slots.map { |slot| (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
while remaining_duration.positive?
max_duration = @reservable.prices.where(group_id: @customer.group_id, plan_id: @plan.try(:id))
.where(Price.arel_table[:duration].lteq(remaining_duration))
.maximum(:duration)
max_duration = 60 if max_duration.nil? || Setting.get('extended_prices_in_same_day') && !all_slots_in_same_day
max_duration_price = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id), duration: max_duration)
current_duration = [remaining_duration, max_duration].min
rates[:prices].push(price: max_duration_price, duration: current_duration)
remaining_duration -= current_duration
end
rates[:prices].sort! { |a, b| b[:duration] <=> a[:duration] }
rates
end
##
# Compute the number of remaining hours in the users current credits (for machine or space)
##

View File

@ -7,5 +7,5 @@ class Price < ApplicationRecord
belongs_to :priceable, polymorphic: true
validates :priceable, :group_id, :amount, presence: true
validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id] }
validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id duration] }
end

View File

@ -121,7 +121,8 @@ class Setting < ApplicationRecord
public_agenda_module
renew_pack_threshold
pack_only_for_subscription
overlapping_categories] }
overlapping_categories
extended_prices_in_same_day] }
# WARNING: when adding a new key, you may also want to add it in:
# - config/locales/en.yml#settings
# - app/frontend/src/javascript/models/setting.ts#SettingName

View File

@ -2,6 +2,14 @@
# Check the access policies for API::PricesController
class PricePolicy < ApplicationPolicy
def create?
user.admin? && record.duration != 60
end
def destroy?
user.admin? && record.duration != 60
end
def update?
user.admin?
end

View File

@ -61,10 +61,7 @@ class PrepaidPackService
## Total number of prepaid minutes available
def minutes_available(user, priceable)
is_pack_only_for_subscription = Setting.find_by(name: "pack_only_for_subscription")&.value
if is_pack_only_for_subscription == 'true' && !user.subscribed_plan
return 0
end
return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan
user_packs = user_packs(user, priceable)
total_available = user_packs.map { |up| up.prepaid_pack.minutes }.reduce(:+) || 0

View File

@ -1,2 +1,4 @@
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id
# frozen_string_literal: true
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id, :duration
json.amount price.amount / 100.0

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.price @amount[:total] / 100.00
json.price_without_coupon @amount[:before_coupon] / 100.00
if @amount[:elements]

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', collection: @prices, as: :price

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.array!(@spaces) do |space|
json.extract! space, :id, :name, :description, :slug, :default_places, :disabled
json.space_image space.space_image.attachment.medium.url if space.space_image

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.extract! @space, :id, :name, :description, :characteristics, :created_at, :updated_at, :slug, :default_places, :disabled
json.space_image @space.space_image.attachment.large.url if @space.space_image
json.space_files_attributes @space.space_files do |f|
@ -9,4 +11,4 @@ end
# using the space in the space_show screen
# json.space_projects @space.projects do |p|
# json.extract! p, :slug, :name
# end
# end

View File

@ -368,6 +368,8 @@ en:
status_enabled: "Enabled"
status_disabled: "Disabled"
status_all: "All"
spaces_pricing:
price_updated: "Price successfully updated"
machines_pricing:
prices_match_machine_hours_rates_html: "The prices below match one hour of machine usage, <strong>without subscription</strong>."
prices_calculated_on_hourly_rate_html: "All the prices will be automatically calculated based on the hourly rate defined here.<br/><em>For example</em>, if you define an hourly rate at {RATE}: a slot of {DURATION} minutes, will be charged <strong>{PRICE}</strong>."
@ -378,6 +380,11 @@ en:
packs: "Prepaid packs"
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
configure_extendedPrices_button:
extendedPrices: "Extended prices"
no_extendedPrices: "No extended price for now"
extended_prices_form:
amount: "Price"
pack_form:
hours: "Hours"
amount: "Price"
@ -404,6 +411,21 @@ en:
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
create_extendedPrice:
new_extendedPrice: "New extended price"
new_extendedPrice_info: "Extended prices allows you to define prices based on custom durations, intead on the default hourly rates."
create_extendedPrice: "Create extended price"
extendedPrice_successfully_created: "The new extended price was successfully created."
delete_extendedPrice:
extendedPrice_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the extended price: "
delete_extendedPrice: "Delete the extended price"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this extended price? This won't be possible if it was already bought by users."
edit_extendedPrice:
edit_extendedPrice: "Edit the extended price"
confirm_changes: "Confirm changes"
extendedPrice_successfully_updated: "The extended price was successfully updated."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Add a coupon"
@ -1235,6 +1257,9 @@ en:
pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription."
pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack"
pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs"
extended_prices: "Extended prices"
extended_prices_info_html: "Spaces can have different prices depending on the cumulated duration of the booking. You can choose if this apply to all bookings or only to those starting within the same day."
extended_prices_in_same_day: "Extended prices in the same day"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"

View File

@ -535,3 +535,4 @@ en:
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"
extended_prices_in_same_day: "Extended prices in the same day"

View File

@ -75,7 +75,7 @@ Rails.application.routes.draw do
get 'pricing' => 'pricing#index'
put 'pricing' => 'pricing#update'
resources :prices, only: %i[index update] do
resources :prices, only: %i[create index update destroy] do
post 'compute', on: :collection
end
resources :prepaid_packs

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# From this migration, we allow Prices to be configured by duration.
# For example, a Price for a 30-minute session could be configured to be twice the price of a 60-minute session.
# This is useful for things like "half-day" sessions, or full-day session when the price is different than the default hour-based price.
class AddDurationToPrice < ActiveRecord::Migration[5.2]
def change
add_column :prices, :duration, :integer, default: 60
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_10_18_121822) do
ActiveRecord::Schema.define(version: 2021_12_20_143400) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -492,6 +492,7 @@ ActiveRecord::Schema.define(version: 2021_10_18_121822) do
t.integer "weight"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "description"
end
create_table "plans", id: :serial, force: :cascade do |t|
@ -554,6 +555,7 @@ ActiveRecord::Schema.define(version: 2021_10_18_121822) do
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "duration", default: 60
t.index ["group_id"], name: "index_prices_on_group_id"
t.index ["plan_id"], name: "index_prices_on_plan_id"
t.index ["priceable_type", "priceable_id"], name: "index_prices_on_priceable_type_and_priceable_id"

View File

@ -905,6 +905,8 @@ unless Setting.find_by(name: 'overlapping_categories').try(:value)
Setting.set('overlapping_categories', 'training_reservations,machine_reservations,space_reservations,events_reservations')
end
Setting.set('extended_prices_in_same_day', true) unless Setting.find_by(name: 'extended_prices_in_same_day').try(:value)
if StatisticCustomAggregation.count.zero?
# available reservations hours for machines
machine_hours = StatisticType.find_by(key: 'hour', statistic_index_id: 2)

View File

@ -6,6 +6,7 @@ price_1:
priceable_id: 1
priceable_type: Machine
amount: 2400
duration: 60
created_at: 2016-04-04 14:11:34.242608000 Z
updated_at: 2016-04-04 14:11:34.242608000 Z
@ -16,6 +17,7 @@ price_2:
priceable_id: 1
priceable_type: Machine
amount: 5300
duration: 60
created_at: 2016-04-04 14:11:34.247363000 Z
updated_at: 2016-04-04 14:11:34.247363000 Z
@ -26,6 +28,7 @@ price_5:
priceable_id: 2
priceable_type: Machine
amount: 4200
duration: 60
created_at: 2016-04-04 14:11:34.290427000 Z
updated_at: 2016-04-04 14:11:34.290427000 Z
@ -36,6 +39,7 @@ price_6:
priceable_id: 2
priceable_type: Machine
amount: 1100
duration: 60
created_at: 2016-04-04 14:11:34.293603000 Z
updated_at: 2016-04-04 14:11:34.293603000 Z
@ -46,6 +50,7 @@ price_9:
priceable_id: 3
priceable_type: Machine
amount: 4100
duration: 60
created_at: 2016-04-04 14:11:34.320809000 Z
updated_at: 2016-04-04 14:11:34.320809000 Z
@ -56,6 +61,7 @@ price_10:
priceable_id: 3
priceable_type: Machine
amount: 5300
duration: 60
created_at: 2016-04-04 14:11:34.325274000 Z
updated_at: 2016-04-04 14:11:34.325274000 Z
@ -66,6 +72,7 @@ price_13:
priceable_id: 4
priceable_type: Machine
amount: 900
duration: 60
created_at: 2016-04-04 14:11:34.362313000 Z
updated_at: 2016-04-04 14:11:34.362313000 Z
@ -76,6 +83,7 @@ price_14:
priceable_id: 4
priceable_type: Machine
amount: 5100
duration: 60
created_at: 2016-04-04 14:11:34.366049000 Z
updated_at: 2016-04-04 14:11:34.366049000 Z
@ -86,6 +94,7 @@ price_17:
priceable_id: 5
priceable_type: Machine
amount: 1600
duration: 60
created_at: 2016-04-04 14:11:34.398206000 Z
updated_at: 2016-04-04 14:11:34.398206000 Z
@ -96,6 +105,7 @@ price_18:
priceable_id: 5
priceable_type: Machine
amount: 2000
duration: 60
created_at: 2016-04-04 14:11:34.407216000 Z
updated_at: 2016-04-04 14:11:34.407216000 Z
@ -106,6 +116,7 @@ price_21:
priceable_id: 6
priceable_type: Machine
amount: 3200
duration: 60
created_at: 2016-04-04 14:11:34.442054000 Z
updated_at: 2016-04-04 14:11:34.442054000 Z
@ -116,6 +127,7 @@ price_22:
priceable_id: 6
priceable_type: Machine
amount: 3400
duration: 60
created_at: 2016-04-04 14:11:34.445147000 Z
updated_at: 2016-04-04 14:11:34.445147000 Z
@ -126,6 +138,7 @@ price_25:
priceable_id: 1
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.038387000 Z
updated_at: 2016-04-04 15:15:45.691674000 Z
@ -136,6 +149,7 @@ price_26:
priceable_id: 2
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.048838000 Z
updated_at: 2016-04-04 15:15:45.693896000 Z
@ -146,6 +160,7 @@ price_27:
priceable_id: 3
priceable_type: Machine
amount: 2500
duration: 60
created_at: 2016-04-04 15:15:21.053412000 Z
updated_at: 2016-04-04 15:15:45.697794000 Z
@ -156,6 +171,7 @@ price_28:
priceable_id: 4
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.057117000 Z
updated_at: 2016-04-04 15:15:45.700657000 Z
@ -166,6 +182,7 @@ price_29:
priceable_id: 5
priceable_type: Machine
amount: 1300
duration: 60
created_at: 2016-04-04 15:15:21.061171000 Z
updated_at: 2016-04-04 15:15:45.707564000 Z
@ -176,6 +193,7 @@ price_30:
priceable_id: 6
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:15:21.065166000 Z
updated_at: 2016-04-04 15:15:45.710945000 Z
@ -186,6 +204,7 @@ price_31:
priceable_id: 1
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.920457000 Z
updated_at: 2016-04-04 15:17:34.255229000 Z
@ -196,6 +215,7 @@ price_32:
priceable_id: 2
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.926967000 Z
updated_at: 2016-04-04 15:17:34.257285000 Z
@ -206,6 +226,7 @@ price_33:
priceable_id: 3
priceable_type: Machine
amount: 2500
duration: 60
created_at: 2016-04-04 15:17:24.932723000 Z
updated_at: 2016-04-04 15:17:34.258741000 Z
@ -216,6 +237,7 @@ price_34:
priceable_id: 4
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.937168000 Z
updated_at: 2016-04-04 15:17:34.260503000 Z
@ -226,6 +248,7 @@ price_35:
priceable_id: 5
priceable_type: Machine
amount: 1300
duration: 60
created_at: 2016-04-04 15:17:24.940520000 Z
updated_at: 2016-04-04 15:17:34.263627000 Z
@ -236,6 +259,7 @@ price_36:
priceable_id: 6
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:17:24.944460000 Z
updated_at: 2016-04-04 15:17:34.267328000 Z
@ -246,6 +270,7 @@ price_37:
priceable_id: 1
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.836899000 Z
updated_at: 2016-04-04 15:18:50.507019000 Z
@ -256,6 +281,7 @@ price_38:
priceable_id: 2
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.842674000 Z
updated_at: 2016-04-04 15:18:50.508799000 Z
@ -266,6 +292,7 @@ price_39:
priceable_id: 3
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:18:28.847736000 Z
updated_at: 2016-04-04 15:18:50.510437000 Z
@ -276,6 +303,7 @@ price_40:
priceable_id: 4
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.852783000 Z
updated_at: 2016-04-04 15:18:50.512239000 Z
@ -286,6 +314,7 @@ price_41:
priceable_id: 5
priceable_type: Machine
amount: 800
duration: 60
created_at: 2016-04-04 15:18:28.856602000 Z
updated_at: 2016-04-04 15:18:50.514062000 Z
@ -296,6 +325,7 @@ price_42:
priceable_id: 6
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.860220000 Z
updated_at: 2016-04-04 15:18:50.517702000 Z
@ -306,6 +336,7 @@ price_43:
priceable_id: 1
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.836899000 Z
updated_at: 2016-04-04 15:18:50.507019000 Z
@ -316,6 +347,7 @@ price_44:
priceable_id: 2
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.842674000 Z
updated_at: 2016-04-04 15:18:50.508799000 Z
@ -326,6 +358,7 @@ price_45:
priceable_id: 3
priceable_type: Machine
amount: 1500
duration: 60
created_at: 2016-04-04 15:18:28.847736000 Z
updated_at: 2016-04-04 15:18:50.510437000 Z
@ -336,6 +369,7 @@ price_46:
priceable_id: 4
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.852783000 Z
updated_at: 2016-04-04 15:18:50.512239000 Z
@ -346,6 +380,7 @@ price_47:
priceable_id: 5
priceable_type: Machine
amount: 800
duration: 60
created_at: 2016-04-04 15:18:28.856602000 Z
updated_at: 2016-04-04 15:18:50.514062000 Z
@ -356,5 +391,6 @@ price_48:
priceable_id: 6
priceable_type: Machine
amount: 1000
duration: 60
created_at: 2016-04-04 15:18:28.860220000 Z
updated_at: 2016-04-04 15:18:50.517702000 Z